diff --git a/packages/svelteui-core/package.json b/packages/svelteui-core/package.json index c1c712c75..0702c288e 100644 --- a/packages/svelteui-core/package.json +++ b/packages/svelteui-core/package.json @@ -65,7 +65,9 @@ }, "dependencies": { "@floating-ui/dom": "1.2.8", - "@stitches/core": "1.2.8" + "@stitches/core": "1.2.8", + "fast-deep-equal": "^3.1.3", + "klona": "^2.0.6" }, "devDependencies": { "@babel/core": "7.21.8", diff --git a/packages/svelteui-core/src/components/Form/Blah.svelte b/packages/svelteui-core/src/components/Form/Blah.svelte new file mode 100644 index 000000000..16679db45 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/Blah.svelte @@ -0,0 +1,111 @@ + + +
(v ? undefined : 'You must agree to the terms we know you didnt read') + }} + validateInputOnChange + validateInputOnBlur + let:form +> + { + console.log({ values }); + })} + on:reset={form.onReset} + > +
+ + + + {#if field.error} +
{field.error}
+ {/if} +
+ + + +

Pokemon

+ {#each form.values.pokemon as _pokemon, i} + + + + + + + + + + {/each} + + + + + + + + {#if field.error} +
{field.error}
+ {/if} +
+ + + + +
+
+
+ diff --git a/packages/svelteui-core/src/components/Form/Field.svelte b/packages/svelteui-core/src/components/Form/Field.svelte new file mode 100644 index 000000000..a217d084b --- /dev/null +++ b/packages/svelteui-core/src/components/Form/Field.svelte @@ -0,0 +1,30 @@ + + + diff --git a/packages/svelteui-core/src/components/Form/Form.d.ts b/packages/svelteui-core/src/components/Form/Form.d.ts new file mode 100644 index 000000000..192c5e562 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/Form.d.ts @@ -0,0 +1,25 @@ +import type { UseFormReturnType } from './src'; +import type { UseFormInput } from './utils/types'; + +export type Values = Record; + +export interface FormProps extends Omit, 'validate'> { + onSubmit?(values: TransformedValues
): void; + onReset?(values: TransformedValues): void; + validation?: Record; +} + +export interface FieldProps { + form: UseFormReturnType; + name: string; + isCheckbox?: boolean; +} + +export interface GetInputPropsReturnType { + value: any; + onChange: any; + checked?: any; + error?: any; + onFocus?: any; + onBlur?: any; +} diff --git a/packages/svelteui-core/src/components/Form/Form.stories.svelte b/packages/svelteui-core/src/components/Form/Form.stories.svelte new file mode 100644 index 000000000..f5134d4e4 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/Form.stories.svelte @@ -0,0 +1,12 @@ + + + + + + + diff --git a/packages/svelteui-core/src/components/Form/Form.svelte b/packages/svelteui-core/src/components/Form/Form.svelte new file mode 100644 index 000000000..526fd5e76 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/Form.svelte @@ -0,0 +1,290 @@ + + + diff --git a/packages/svelteui-core/src/components/Form/formContext.ts b/packages/svelteui-core/src/components/Form/formContext.ts new file mode 100644 index 000000000..796177a1a --- /dev/null +++ b/packages/svelteui-core/src/components/Form/formContext.ts @@ -0,0 +1 @@ +export const key = Symbol(); diff --git a/packages/svelteui-core/src/components/Form/index.ts b/packages/svelteui-core/src/components/Form/index.ts new file mode 100644 index 000000000..38cde1139 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/index.ts @@ -0,0 +1,2 @@ +export { default as Form } from './Form.svelte'; +export type { FormProps } from './Form'; diff --git a/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.test.ts b/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.test.ts new file mode 100644 index 000000000..ba5c76a7b --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.test.ts @@ -0,0 +1,12 @@ +import { describe, it, expect } from 'vitest'; +import { filterErrors } from './filter-errors'; + +describe('@mantine/form/filter-errors', () => { + it('filters null, false and undefined errors', () => { + expect(filterErrors({ a: null, b: 0, c: '', d: false, f: undefined, g: [] })).toStrictEqual({ + b: 0, + c: '', + g: [] + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.ts b/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.ts new file mode 100644 index 000000000..116320723 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/filter-errors/filter-errors.ts @@ -0,0 +1,17 @@ +import type { FormErrors } from '../types'; + +export function filterErrors(errors: FormErrors): FormErrors { + if (errors === null || typeof errors !== 'object') { + return {}; + } + + return Object.keys(errors).reduce((acc, key) => { + const errorValue = errors[key]; + + if (errorValue !== undefined && errorValue !== null && errorValue !== false) { + acc[key] = errorValue; + } + + return acc; + }, {}); +} diff --git a/packages/svelteui-core/src/components/Form/utils/filter-errors/index.ts b/packages/svelteui-core/src/components/Form/utils/filter-errors/index.ts new file mode 100644 index 000000000..61b555bf1 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/filter-errors/index.ts @@ -0,0 +1 @@ +export { filterErrors } from './filter-errors'; diff --git a/packages/svelteui-core/src/components/Form/utils/form-index.ts b/packages/svelteui-core/src/components/Form/utils/form-index.ts new file mode 100644 index 000000000..fe3ac014c --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/form-index.ts @@ -0,0 +1 @@ +export const FORM_INDEX = '__SVELTEUI_FORM_INDEX__'; diff --git a/packages/svelteui-core/src/components/Form/utils/get-input-on-change/get-input-on-change.ts b/packages/svelteui-core/src/components/Form/utils/get-input-on-change/get-input-on-change.ts new file mode 100644 index 000000000..a93134d4c --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/get-input-on-change/get-input-on-change.ts @@ -0,0 +1,27 @@ +export function getInputOnChange( + setValue: (value: Value | ((current: Value) => Value)) => void +) { + return (val: Value | React.ChangeEvent | ((current: Value) => Value)) => { + if (!val) { + setValue(val as Value); + } else if (typeof val === 'function') { + setValue(val); + } else if (typeof val === 'object' && 'nativeEvent' in val) { + const { currentTarget } = val; + if (currentTarget instanceof HTMLInputElement) { + if (currentTarget.type === 'checkbox') { + setValue(currentTarget.checked as any); + } else { + setValue(currentTarget.value as any); + } + } else if ( + currentTarget instanceof HTMLTextAreaElement || + currentTarget instanceof HTMLSelectElement + ) { + setValue(currentTarget.value as any); + } + } else { + setValue(val); + } + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/get-input-on-change/index.ts b/packages/svelteui-core/src/components/Form/utils/get-input-on-change/index.ts new file mode 100644 index 000000000..716986909 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/get-input-on-change/index.ts @@ -0,0 +1 @@ +export { getInputOnChange } from './get-input-on-change'; diff --git a/packages/svelteui-core/src/components/Form/utils/get-status/get-status.test.ts b/packages/svelteui-core/src/components/Form/utils/get-status/get-status.test.ts new file mode 100644 index 000000000..207ac1d36 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/get-status/get-status.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { getStatus } from './get-status'; + +const TEST_STATUS = { + e: false, + b: true, + 'a.4.c': true, + 'c.d.e': false, + 'l.d.0.1.e': true +}; + +describe('@mantine/form/get-status', () => { + it('returns correct status for given absolute path', () => { + expect(getStatus(TEST_STATUS, 'b')).toBe(true); + expect(getStatus(TEST_STATUS, 'e')).toBe(false); + expect(getStatus(TEST_STATUS, 'unknown')).toBe(false); + }); + + it('returns correct status for given nested path', () => { + expect(getStatus(TEST_STATUS, 'a.4.c')).toBe(true); + expect(getStatus(TEST_STATUS, 'c.d.e')).toBe(false); + expect(getStatus(TEST_STATUS, 'unknown.path')).toBe(false); + }); + + it('returns correct status for computed path', () => { + expect(getStatus(TEST_STATUS, 'a')).toBe(true); + expect(getStatus(TEST_STATUS, 'a.4')).toBe(true); + expect(getStatus(TEST_STATUS, 'a.5')).toBe(false); + expect(getStatus(TEST_STATUS, 'l.d.0')).toBe(true); + expect(getStatus(TEST_STATUS, 'l.d.0.2')).toBe(false); + expect(getStatus(TEST_STATUS, 'd.0')).toBe(false); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/get-status/get-status.ts b/packages/svelteui-core/src/components/Form/utils/get-status/get-status.ts new file mode 100644 index 000000000..8736c59a4 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/get-status/get-status.ts @@ -0,0 +1,12 @@ +import type { FormStatus } from '../types'; + +export function getStatus(status: FormStatus, path?: unknown) { + const paths = Object.keys(status); + + if (typeof path === 'string') { + const nestedPaths = paths.filter((statusPath) => statusPath.startsWith(`${path}.`)); + return status[path] || nestedPaths.some((statusPath) => status[statusPath]) || false; + } + + return paths.some((statusPath) => status[statusPath]); +} diff --git a/packages/svelteui-core/src/components/Form/utils/get-status/index.ts b/packages/svelteui-core/src/components/Form/utils/get-status/index.ts new file mode 100644 index 000000000..070cd7b5f --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/get-status/index.ts @@ -0,0 +1 @@ +export { getStatus } from './get-status'; diff --git a/packages/svelteui-core/src/components/Form/utils/index.ts b/packages/svelteui-core/src/components/Form/utils/index.ts new file mode 100644 index 000000000..c2f19dcb3 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/index.ts @@ -0,0 +1,9 @@ +export { FORM_INDEX } from './form-index'; +export * from './validators'; + +export { zodResolver } from './resolvers/zod-resolver/zod-resolver'; +export { superstructResolver } from './resolvers/superstruct-resolver/superstruct-resolver'; +export { yupResolver } from './resolvers/yup-resolver/yup-resolver'; +export { joiResolver } from './resolvers/joi-resolver/joi-resolver'; + +export type { FormErrors, UseFormReturnType, TransformedValues } from './types'; diff --git a/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.test.ts b/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.test.ts new file mode 100644 index 000000000..4582ee290 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from 'vitest'; +import { changeErrorIndices } from './change-error-indices'; + +const TEST_ERRORS = { + name: 'name-error', + + // single level of nesting + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + 'fruits.4.available': 'fruit-error-3', + 'fruits.15.inner.name': 'fruit-error-4', + 'fruits.15.inner.0.name': 'fruit-error-5', + + // multiple levels of nesting + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.2.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.2.check': 'nested-error-3', + 'nested.0.inner.5.check': 'nested-error-4' +}; + +describe('@mantine/form/change-error-indices', () => { + it('increments error indices', () => { + expect(changeErrorIndices('fruits', 4, TEST_ERRORS, 1)).toStrictEqual({ + name: 'name-error', + // Errors with index lower than the given one don't change + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + // Increment everything else + 'fruits.5.available': 'fruit-error-3', + 'fruits.16.inner.name': 'fruit-error-4', + 'fruits.16.inner.0.name': 'fruit-error-5', + // Ignore non-matching paths + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.2.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.2.check': 'nested-error-3', + 'nested.0.inner.5.check': 'nested-error-4' + }); + }); + + it('decrements error indices and removes errors for the removed element', () => { + expect(changeErrorIndices('fruits', 4, TEST_ERRORS, -1)).toStrictEqual({ + name: 'name-error', + // Errors with index lower than the given one don't change + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + // Remove the error with the given index + // Decrement everything else + 'fruits.14.inner.name': 'fruit-error-4', + 'fruits.14.inner.0.name': 'fruit-error-5', + // Ignore non-matching paths + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.2.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.2.check': 'nested-error-3', + 'nested.0.inner.5.check': 'nested-error-4' + }); + }); + + it('increments deeply nested errors', () => { + expect(changeErrorIndices('nested.0.inner', 2, TEST_ERRORS, 1)).toStrictEqual({ + name: 'name-error', + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + 'fruits.4.available': 'fruit-error-3', + 'fruits.15.inner.name': 'fruit-error-4', + 'fruits.15.inner.0.name': 'fruit-error-5', + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.3.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.3.check': 'nested-error-3', + 'nested.0.inner.6.check': 'nested-error-4' + }); + }); + + it('decrements deeply nested errors and removes errors for the removed element', () => { + expect(changeErrorIndices('nested.0.inner', 2, TEST_ERRORS, -1)).toStrictEqual({ + name: 'name-error', + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + 'fruits.4.available': 'fruit-error-3', + 'fruits.15.inner.name': 'fruit-error-4', + 'fruits.15.inner.0.name': 'fruit-error-5', + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.4.check': 'nested-error-4' + }); + }); + + describe('returns unchanged object', () => { + it('if index is undefined', () => { + expect(changeErrorIndices('fruits', undefined, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS); + }); + + it('if path is not a string', () => { + expect(changeErrorIndices(1, 1, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS); + }); + + it('if path does not exist', () => { + expect(changeErrorIndices('does-not-exist', 1, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS); + }); + + it('if index is bigger than any error index', () => { + expect(changeErrorIndices('fruits', 100, TEST_ERRORS, 1)).toStrictEqual(TEST_ERRORS); + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.ts b/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.ts new file mode 100644 index 000000000..1a65dc8f7 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/change-error-indices.ts @@ -0,0 +1,59 @@ +import { clearListState } from './clear-list-state'; + +/** + * Gets the part of the key after the path which can be an index + */ +function getIndexFromKeyAfterPath(key: string, path: string): number { + const split = key.substring(path.length + 1).split('.')[0]; + return parseInt(split, 10); +} + +/** + * Changes the indices of every error that is after the given `index` with the given `change` at the given `path`. + * This requires that the errors are in the format of `path.index` and that the index is a number. + */ +export function changeErrorIndices>( + path: PropertyKey, + index: number, + errors: T, + change: 1 | -1 +): T { + if (index === undefined) { + return errors; + } + const pathString = `${String(path)}`; + let clearedErrors = errors; + // Remove all errors if the corresponding item was removed + if (change === -1) { + clearedErrors = clearListState(`${pathString}.${index}`, clearedErrors); + } + + const cloned = { ...clearedErrors }; + const changedKeys = new Set(); + Object.entries(clearedErrors) + .filter(([key]) => { + if (!key.startsWith(`${pathString}.`)) { + return false; + } + const currIndex = getIndexFromKeyAfterPath(key, pathString); + if (Number.isNaN(currIndex)) { + return false; + } + return currIndex >= index; + }) + .forEach(([key, value]) => { + const currIndex = getIndexFromKeyAfterPath(key, pathString); + + const newKey: keyof T = key.replace( + `${pathString}.${currIndex}`, + `${pathString}.${currIndex + change}` + ); + cloned[newKey] = value; + changedKeys.add(newKey); + if (!changedKeys.has(key)) { + delete cloned[key]; + } + }); + + return cloned; +} diff --git a/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.test.ts b/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.test.ts new file mode 100644 index 000000000..e57811710 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.test.ts @@ -0,0 +1,53 @@ +import { describe, it, expect } from 'vitest'; +import { clearListState } from './clear-list-state'; + +const TEST_ERRORS = { + name: 'name-error', + + // single level of nesting + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + 'fruits.4.available': 'fruit-error-3', + 'fruits.15.inner.name': 'fruit-error-4', + 'fruits.15.inner.0.name': 'fruit-error-5', + + // multiple levels of nesting + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.2.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.2.check': 'nested-error-3', + 'nested.0.inner.5.check': 'nested-error-4' +}; + +describe('@mantine/form/clear-list-state', () => { + it('clears list errors of given field', () => { + expect(clearListState('fruits', TEST_ERRORS)).toStrictEqual({ + name: 'name-error', + 'nested.0.inner.1.name': 'nested-error-1', + 'nested.0.inner.2.name': 'nested-error-2', + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2', + 'nested.0.inner.2.check': 'nested-error-3', + 'nested.0.inner.5.check': 'nested-error-4' + }); + }); + + it('clears deeply nested errors', () => { + expect(clearListState('nested.0.inner', TEST_ERRORS)).toStrictEqual({ + name: 'name-error', + + 'fruits.0.name': 'fruit-error-1', + 'fruits.0.available': 'fruit-error-2', + 'fruits.4.available': 'fruit-error-3', + 'fruits.15.inner.name': 'fruit-error-4', + 'fruits.15.inner.0.name': 'fruit-error-5', + + 'nested.2.inner.2.name': 'keep-nested-error-1', + 'nested.3.inner.0.name': 'keep-nested-error-2', + 'nested.5.inner.1.check': 'keep-nested-error-2' + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.ts b/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.ts new file mode 100644 index 000000000..6f2b3d702 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/clear-list-state.ts @@ -0,0 +1,17 @@ +export function clearListState>( + field: PropertyKey, + state: T +): T { + if (state === null || typeof state !== 'object') { + return {} as T; + } + + const clone = { ...state }; + Object.keys(state).forEach((errorKey) => { + if (errorKey.includes(`${String(field)}.`)) { + delete clone[errorKey]; + } + }); + + return clone; +} diff --git a/packages/svelteui-core/src/components/Form/utils/lists/index.ts b/packages/svelteui-core/src/components/Form/utils/lists/index.ts new file mode 100644 index 000000000..d1e4c7261 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/index.ts @@ -0,0 +1,3 @@ +export { clearListState } from './clear-list-state'; +export { changeErrorIndices } from './change-error-indices'; +export { reorderErrors } from './reorder-errors'; diff --git a/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.test.ts b/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.test.ts new file mode 100644 index 000000000..aec4d8a7b --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { reorderErrors } from './reorder-errors'; + +describe('@mantine/form/reorder-errors', () => { + it('reorders errors at given path', () => { + expect(reorderErrors('a', { from: 2, to: 0 }, { 'a.0': true })).toStrictEqual({ + 'a.2': true + }); + expect(reorderErrors('a', { from: 2, to: 0 }, { 'a.0': true, 'a.2': 'Error' })).toStrictEqual({ + 'a.0': 'Error', + 'a.2': true + }); + }); + + it('returns unchanged object if path does not exist', () => { + const errors = { 'a.0': true }; + expect(reorderErrors('c', { from: 1, to: 2 }, errors)).toStrictEqual(errors); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.ts b/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.ts new file mode 100644 index 000000000..da2a162d7 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/lists/reorder-errors.ts @@ -0,0 +1,30 @@ +import type { ReorderPayload } from '../types'; + +export function reorderErrors(path: unknown, { from, to }: ReorderPayload, errors: T): T { + const oldKeyStart = `${path}.${from}`; + const newKeyStart = `${path}.${to}`; + + const clone = { ...errors }; + Object.keys(errors).every((key) => { + let oldKey; + let newKey; + if (key.startsWith(oldKeyStart)) { + oldKey = key; + newKey = key.replace(oldKeyStart, newKeyStart); + } + if (key.startsWith(newKeyStart)) { + oldKey = key.replace(newKeyStart, oldKeyStart); + newKey = key; + } + if (oldKey && newKey) { + const value1 = clone[oldKey]; + const value2 = clone[newKey]; + value2 === undefined ? delete clone[oldKey] : (clone[oldKey] = value2); + value1 === undefined ? delete clone[newKey] : (clone[newKey] = value1); + return false; + } + return true; + }); + + return clone; +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/get-path.test.ts b/packages/svelteui-core/src/components/Form/utils/paths/get-path.test.ts new file mode 100644 index 000000000..e003df94f --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/get-path.test.ts @@ -0,0 +1,79 @@ +import { describe, it, expect } from 'vitest'; +import { getPath } from './get-path'; + +const values = { + name: 'John', + age: 42, + description: ['male', 'mid-age'], + job: { + title: 'Engineer', + permissions: { + admin: false, + user: true + } + }, + + duties: [ + { + title: 'Drink coffee', + when: [{ morning: true }, { afternoon: false }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'log' }, + { date: 'today', event: 'log' } + ] + } + }, + { + title: 'Do the job', + when: [{ morning: false }, { afternoon: true }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'log' }, + { date: 'today', event: 'log' } + ] + } + } + ] +}; + +describe('@mantine/form/get-path', () => { + it('supports getting property from root', () => { + expect(getPath('name', values)).toBe('John'); + expect(getPath('age', values)).toBe(42); + }); + + it('supports getting nested object property', () => { + expect(getPath('job.title', values)).toBe('Engineer'); + expect(getPath('job.permissions', values)).toStrictEqual({ admin: false, user: true }); + expect(getPath('job.permissions.user', values)).toBe(true); + }); + + it('supports getting array item', () => { + expect(getPath('description.0', values)).toBe('male'); + expect(getPath('description.1', values)).toBe('mid-age'); + }); + + it('supports getting property of an object nested in an array', () => { + expect(getPath('duties.0.title', values)).toBe('Drink coffee'); + expect(getPath('duties.1.title', values)).toBe('Do the job'); + }); + + it('supports multiple nesting', () => { + expect(getPath('duties.0.when.0.morning', values)).toBe(true); + expect(getPath('duties.1.info.activity.0.date', values)).toBe('yesterday'); + }); + + it('returns undefined if path cannot be found', () => { + expect(getPath('random', values)).toBe(undefined); + expect(getPath('name.random.path', values)).toBe(undefined); + expect(getPath('duties.3.title', values)).toBe(undefined); + }); + + it('correctly handles undefined and null values', () => { + expect(getPath('a.b.c', undefined)).toBe(undefined); + expect(getPath('a.b.c', null)).toBe(undefined); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/paths/get-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/get-path.ts new file mode 100644 index 000000000..94ccbaa58 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/get-path.ts @@ -0,0 +1,20 @@ +import { getSplittedPath } from './get-splitted-path'; + +export function getPath(path: unknown, values: unknown): unknown { + const splittedPath = getSplittedPath(path); + + if (splittedPath.length === 0 || typeof values !== 'object' || values === null) { + return undefined; + } + + let value = values[splittedPath[0]]; + for (let i = 1; i < splittedPath.length; i += 1) { + if (value === undefined) { + break; + } + + value = value[splittedPath[i]]; + } + + return value; +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/get-splitted-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/get-splitted-path.ts new file mode 100644 index 000000000..1adf45343 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/get-splitted-path.ts @@ -0,0 +1,7 @@ +export function getSplittedPath(path: unknown) { + if (typeof path !== 'string') { + return []; + } + + return path.split('.'); +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/index.ts b/packages/svelteui-core/src/components/Form/utils/paths/index.ts new file mode 100644 index 000000000..04b649c69 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/index.ts @@ -0,0 +1,5 @@ +export { getPath } from './get-path'; +export { setPath } from './set-path'; +export { reorderPath } from './reorder-path'; +export { insertPath } from './insert-path'; +export { removePath } from './remove-path'; diff --git a/packages/svelteui-core/src/components/Form/utils/paths/insert-path.test.ts b/packages/svelteui-core/src/components/Form/utils/paths/insert-path.test.ts new file mode 100644 index 000000000..8471b42e1 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/insert-path.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { insertPath } from './insert-path'; + +describe('@mantine/form/insert-path', () => { + it('inserts item at given index', () => { + expect(insertPath('a', 4, 1, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 4, 2, 3] }); + }); + + it('appends item to the end of the list if index is undefined', () => { + expect(insertPath('a', 4, undefined, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 2, 3, 4] }); + }); + + it('supports nested lists', () => { + expect( + insertPath('a.1.b', 4, undefined, { + a: [{ b: [1, 2, 3] }, { b: [1, 2, 3] }] + }) + ).toStrictEqual({ + a: [{ b: [1, 2, 3] }, { b: [1, 2, 3, 4] }] + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/paths/insert-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/insert-path.ts new file mode 100644 index 000000000..ea4506115 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/insert-path.ts @@ -0,0 +1,15 @@ +import { getPath } from './get-path'; +import { setPath } from './set-path'; + +export function insertPath(path: unknown, value: unknown, index: number, values: T) { + const currentValue = getPath(path, values); + + if (!Array.isArray(currentValue)) { + return values; + } + + const cloned = [...currentValue]; + cloned.splice(typeof index === 'number' ? index : cloned.length, 0, value); + + return setPath(path, cloned, values); +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/remove-path.test.ts b/packages/svelteui-core/src/components/Form/utils/paths/remove-path.test.ts new file mode 100644 index 000000000..0978175eb --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/remove-path.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { removePath } from './remove-path'; + +describe('@mantine/form/remove-path', () => { + it('removes list item at given path (root property)', () => { + expect(removePath('a', 1, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 3] }); + }); + + it('returns unchanged list when path does not exist', () => { + expect(removePath('a.d.a', 1, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 2, 3] }); + expect(removePath('b.c.d.0', 1, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 2, 3] }); + }); + + it('removes list item at give path (nested lists)', () => { + expect(removePath('a.0.b', 1, { a: [{ b: [1, 2, 3] }, { b: [1, 2, 3] }] })).toStrictEqual({ + a: [{ b: [1, 3] }, { b: [1, 2, 3] }] + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/paths/remove-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/remove-path.ts new file mode 100644 index 000000000..6b439a5cf --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/remove-path.ts @@ -0,0 +1,16 @@ +import { getPath } from './get-path'; +import { setPath } from './set-path'; + +export function removePath(path: unknown, index: number, values: T) { + const currentValue = getPath(path, values); + + if (!Array.isArray(currentValue)) { + return values; + } + + return setPath( + path, + currentValue.filter((_, itemIndex) => itemIndex !== index), + values + ); +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.test.ts b/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.test.ts new file mode 100644 index 000000000..9894193f7 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { reorderPath } from './reorder-path'; + +describe('@mantine/form/reorder-path', () => { + it('reorders array items at given root path', () => { + expect(reorderPath('a', { from: 2, to: 0 }, { a: [1, 2, 3] })).toStrictEqual({ a: [3, 1, 2] }); + expect(reorderPath('a', { from: 1, to: 2 }, { a: [1, 2, 3] })).toStrictEqual({ a: [1, 3, 2] }); + }); + + it('reorders array items at given nested path', () => { + expect( + reorderPath('a.0.b', { from: 2, to: 0 }, { a: [{ b: [1, 2, 3] }, { b: [1, 2, 3] }] }) + ).toStrictEqual({ + a: [{ b: [3, 1, 2] }, { b: [1, 2, 3] }] + }); + + expect( + reorderPath('a.1.b', { from: 1, to: 2 }, { a: [{ b: [1, 2, 3] }, { b: [1, 2, 3] }] }) + ).toStrictEqual({ + a: [{ b: [1, 2, 3] }, { b: [1, 3, 2] }] + }); + }); + + it('returns unchanged object if path does not exist', () => { + expect(reorderPath('c', { from: 1, to: 2 }, { a: 1, b: 2 })).toStrictEqual({ a: 1, b: 2 }); + expect(reorderPath('a.c', { from: 1, to: 2 }, { a: 1, b: 2 })).toStrictEqual({ a: 1, b: 2 }); + }); + + it('returns unchanged object if value at path is not an array', () => { + expect(reorderPath('a.c', { from: 1, to: 2 }, { a: { c: 1 }, b: 2 })).toStrictEqual({ + a: { c: 1 }, + b: 2 + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.ts new file mode 100644 index 000000000..50d19a992 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/reorder-path.ts @@ -0,0 +1,18 @@ +import { getPath } from './get-path'; +import { setPath } from './set-path'; +import type { ReorderPayload } from '../types'; + +export function reorderPath(path: unknown, { from, to }: ReorderPayload, values: T) { + const currentValue = getPath(path, values); + + if (!Array.isArray(currentValue)) { + return values; + } + + const cloned = [...currentValue]; + const item = currentValue[from]; + cloned.splice(from, 1); + cloned.splice(to, 0, item); + + return setPath(path, cloned, values); +} diff --git a/packages/svelteui-core/src/components/Form/utils/paths/set-path.test.ts b/packages/svelteui-core/src/components/Form/utils/paths/set-path.test.ts new file mode 100644 index 000000000..bf65a6932 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/set-path.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect } from 'vitest'; +import { setPath } from './set-path'; + +const values = { + name: 'John', + age: 42, + description: ['male', 'mid-age'], + job: { + title: 'Engineer', + permissions: { + admin: false, + user: true + } + }, + + duties: [ + { + title: 'Drink coffee', + when: [{ morning: true }, { afternoon: false }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'log' }, + { date: 'today', event: 'log' } + ] + } + }, + { + title: 'Do the job', + when: [{ morning: false }, { afternoon: true }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'log' }, + { date: 'today', event: 'log' } + ] + } + } + ] +}; + +describe('@mantine/form/set-path', () => { + it('sets value at root property', () => { + expect(setPath('name', 'Jane', values)).toStrictEqual({ ...values, name: 'Jane' }); + expect(setPath('age', 25, values)).toStrictEqual({ ...values, age: 25 }); + }); + + it('sets plain array path', () => { + expect(setPath('description.1', 'young', values)).toStrictEqual({ + ...values, + description: ['male', 'young'] + }); + expect(setPath('description.0', 'female', values)).toStrictEqual({ + ...values, + description: ['female', 'mid-age'] + }); + }); + + it('sets nested object path', () => { + expect(setPath('job.permissions.user', false, values)).toStrictEqual({ + ...values, + job: { + title: 'Engineer', + permissions: { + admin: false, + user: false + } + } + }); + }); + + it('sets deeply nested path', () => { + expect(setPath('duties.1.info.activity.0.event', 'test', values)).toStrictEqual({ + ...values, + duties: [ + { + title: 'Drink coffee', + when: [{ morning: true }, { afternoon: false }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'log' }, + { date: 'today', event: 'log' } + ] + } + }, + { + title: 'Do the job', + when: [{ morning: false }, { afternoon: true }, { evening: false }], + info: { + active: true, + activity: [ + { date: 'yesterday', event: 'test' }, + { date: 'today', event: 'log' } + ] + } + } + ] + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/paths/set-path.ts b/packages/svelteui-core/src/components/Form/utils/paths/set-path.ts new file mode 100644 index 000000000..ca6aad6ea --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/paths/set-path.ts @@ -0,0 +1,31 @@ +import { klona } from 'klona/full'; +import { getSplittedPath } from './get-splitted-path'; + +export function setPath(path: unknown, value: unknown, values: T) { + const splittedPath = getSplittedPath(path); + + if (splittedPath.length === 0) { + return values; + } + + const cloned = klona(values); + + if (splittedPath.length === 1) { + cloned[splittedPath[0]] = value; + return cloned; + } + + let val = cloned[splittedPath[0]]; + + for (let i = 1; i < splittedPath.length - 1; i += 1) { + if (val === undefined) { + return cloned; + } + + val = val[splittedPath[i]]; + } + + val[splittedPath[splittedPath.length - 1]] = value; + + return cloned; +} diff --git a/packages/svelteui-core/src/components/Form/utils/resolvers/joi-resolver/joi-resolver.ts b/packages/svelteui-core/src/components/Form/utils/resolvers/joi-resolver/joi-resolver.ts new file mode 100644 index 000000000..ebdaa43c4 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/resolvers/joi-resolver/joi-resolver.ts @@ -0,0 +1,35 @@ +import type { FormErrors } from '../../types'; + +interface JoiError { + path: (string | number)[]; + message: string; +} + +interface JoiResults { + success: boolean; + error: { + details: JoiError[]; + }; +} + +interface JoiSchema { + validate(values: Record, options: { abortEarly: boolean }): JoiResults; +} + +export function joiResolver(schema: any, options?: any) { + const _schema: JoiSchema = schema; + return (values: Record): FormErrors => { + const parsed = _schema.validate(values, { abortEarly: false, ...options }); + + if (!parsed.error) { + return {}; + } + + const results = {}; + parsed.error.details.forEach((error) => { + results[error.path.join('.')] = error.message; + }); + + return results; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/resolvers/superstruct-resolver/superstruct-resolver.ts b/packages/svelteui-core/src/components/Form/utils/resolvers/superstruct-resolver/superstruct-resolver.ts new file mode 100644 index 000000000..1ea5e0386 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/resolvers/superstruct-resolver/superstruct-resolver.ts @@ -0,0 +1,36 @@ +import type { FormErrors } from '../../types'; + +type StructFailure = { + value: any; + key: any; + type: string; + refinement: string | undefined; + message: string; + explanation?: string; + branch: Array; + path: Array; +}; + +type StructValidaationError = { + failures: () => Array; +}; + +export function superstructResolver(schema: any) { + function structValidation(values: Record): FormErrors { + const formErrors: FormErrors = {}; + + const [err]: [StructValidaationError | null, unknown] = schema.validate(values); + if (!err) { + return formErrors; + } + + err.failures().forEach((fieldFailure) => { + const fieldName = fieldFailure.path.join(' '); + formErrors[fieldFailure.path.join('.')] = `${fieldName}: ${fieldFailure.message}`; + }); + + return formErrors; + } + + return structValidation; +} diff --git a/packages/svelteui-core/src/components/Form/utils/resolvers/yup-resolver/yup-resolver.ts b/packages/svelteui-core/src/components/Form/utils/resolvers/yup-resolver/yup-resolver.ts new file mode 100644 index 000000000..a0663f006 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/resolvers/yup-resolver/yup-resolver.ts @@ -0,0 +1,34 @@ +import type { FormErrors } from '../../types'; + +interface YupError { + path: string; + message: string; +} + +interface YupValidationResult { + inner: YupError[]; +} + +interface YupSchema { + validateSync(values: Record, options: { abortEarly: boolean }): void; +} + +export function yupResolver(schema: any) { + const _schema: YupSchema = schema; + + return (values: Record): FormErrors => { + try { + _schema.validateSync(values, { abortEarly: false }); + return {}; + } catch (_yupError) { + const yupError: YupValidationResult = _yupError; + const results = {}; + + yupError.inner.forEach((error) => { + results[error.path.replaceAll('[', '.').replaceAll(']', '')] = error.message; + }); + + return results; + } + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/resolvers/zod-resolver/zod-resolver.ts b/packages/svelteui-core/src/components/Form/utils/resolvers/zod-resolver/zod-resolver.ts new file mode 100644 index 000000000..f72c9d972 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/resolvers/zod-resolver/zod-resolver.ts @@ -0,0 +1,39 @@ +import type { FormErrors } from '../../types'; + +interface ZodError { + path: (string | number)[]; + message: string; +} + +interface ZodParseSuccess { + success: true; +} + +interface ZodParseError { + success: false; + error: { + errors: ZodError[]; + }; +} + +interface ZodSchema> { + safeParse(values: T): ZodParseSuccess | ZodParseError; +} + +export function zodResolver>(schema: ZodSchema) { + return (values: T): FormErrors => { + const parsed = schema.safeParse(values); + + if (parsed.success) { + return {}; + } + + const results = {}; + + (parsed as ZodParseError).error.errors.forEach((error) => { + results[error.path.join('.')] = error.message; + }); + + return results; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/types.ts b/packages/svelteui-core/src/components/Form/utils/types.ts new file mode 100644 index 000000000..fee33d721 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/types.ts @@ -0,0 +1,167 @@ +export type GetInputPropsType = 'input' | 'checkbox'; + +export type FormStatus = Record; + +export interface FormFieldValidationResult { + hasError: boolean; + error: string; +} + +export interface FormValidationResult { + hasErrors: boolean; + errors: FormErrors; +} + +export type FormErrors = Record; + +export interface ReorderPayload { + from: number; + to: number; +} + +type Rule = (value: Value, values: Values, path: string) => string; + +type FormRule = NonNullable extends Array + ? + | Partial<{ + [Key in keyof ListValue]: ListValue[Key] extends Array + ? FormRulesRecord | Rule + : FormRulesRecord | Rule; + }> + | Rule + : NonNullable extends Record + ? FormRulesRecord | Rule + : Rule; + +export type FormRulesRecord = Partial<{ + [Key in keyof Values]: FormRule; +}>; + +export type FormValidateInput = FormRulesRecord | ((values: Values) => FormErrors); + +export type LooseKeys = keyof Values | (string & object); + +export type SetValues = React.Dispatch>>; +export type SetErrors = React.Dispatch>; +export type SetFormStatus = React.Dispatch>; + +export type OnSubmit> = ( + handleSubmit: ( + values: ReturnType, + event: Event & { readonly submitter: HTMLElement } + ) => void, + handleValidationFailure?: ( + errors: FormErrors, + values: Values, + event: Event & { readonly submitter: HTMLElement } + ) => void +) => (event?: Event & { readonly submitter: HTMLElement }) => void; + +export type GetTransformedValues> = ( + values?: Values +) => ReturnType; + +export type OnReset = (event: Event & { readonly currentTarget: HTMLElement }) => void; + +export type GetInputProps = >( + path: Field, + options?: { type?: GetInputPropsType; withError?: boolean; withFocus?: boolean } +) => { value: any; onChange: any; checked?: any; error?: any; onFocus?: any; onBlur?: any }; + +export type SetFieldValue = >( + path: Field, + value: Field extends keyof Values ? Values[Field] : unknown +) => void; + +export type ClearFieldError = (path: unknown) => void; +export type ClearFieldDirty = (path: unknown) => void; +export type ClearErrors = () => void; +export type Reset = () => void; +export type Validate = () => FormValidationResult; +export type ValidateField = >( + path: Field +) => FormFieldValidationResult; + +export type SetFieldError = >( + path: Field, + error: string +) => void; + +export type ReorderListItem = >( + path: Field, + payload: ReorderPayload +) => void; + +export type InsertListItem = >( + path: Field, + item: unknown, + index?: number +) => void; + +export type RemoveListItem = >( + path: Field, + index: number +) => void; + +export type GetFieldStatus = >(path?: Field) => boolean; +export type ResetStatus = () => void; + +export type ResetDirty = (values?: Values) => void; +export type IsValid = >(path?: Field) => boolean; + +export type _TransformValues = (values: Values) => unknown; + +export interface UseFormInput< + Values, + TransformValues extends _TransformValues = (values: Values) => Values +> { + initialValues?: Values; + initialErrors?: FormErrors; + initialTouched?: FormStatus; + initialDirty?: FormStatus; + transformValues?: TransformValues; + validate?: FormValidateInput; + clearInputErrorOnChange?: boolean; + validateInputOnChange?: boolean | LooseKeys[]; + validateInputOnBlur?: boolean | LooseKeys[]; +} + +export interface UseFormReturnType< + Values, + TransformValues extends _TransformValues = (values: Values) => Values +> { + values: Values; + errors: FormErrors; + setValues: SetValues; + setErrors: SetErrors; + setFieldValue: SetFieldValue; + setFieldError: SetFieldError; + clearFieldError: ClearFieldError; + clearErrors: ClearErrors; + reset: Reset; + validate: Validate; + validateField: ValidateField; + reorderListItem: ReorderListItem; + removeListItem: RemoveListItem; + insertListItem: InsertListItem; + getInputProps: GetInputProps; + onSubmit: OnSubmit; + onReset: OnReset; + isDirty: GetFieldStatus; + isTouched: GetFieldStatus; + setTouched: SetFormStatus; + setDirty: SetFormStatus; + resetTouched: ResetStatus; + resetDirty: ResetDirty; + isValid: IsValid; + getTransformedValues: GetTransformedValues; +} + +export type UseForm< + Values = Record, + TransformValues extends _TransformValues = (values: Values) => Values +> = (input?: UseFormInput) => UseFormReturnType; + +export type TransformedValues> = Parameters< + Parameters[0] +>[0]; diff --git a/packages/svelteui-core/src/components/Form/utils/validate/index.ts b/packages/svelteui-core/src/components/Form/utils/validate/index.ts new file mode 100644 index 000000000..7a1a7421f --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/index.ts @@ -0,0 +1,3 @@ +export { validateValues } from './validate-values'; +export { validateFieldValue } from './validate-field-value'; +export { shouldValidateOnChange } from './should-validate-on-change'; diff --git a/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.test.ts b/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.test.ts new file mode 100644 index 000000000..165b90eff --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.test.ts @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { FORM_INDEX } from '../form-index'; +import { shouldValidateOnChange } from './should-validate-on-change'; + +describe('@mantine/form/should-validate-on-change', () => { + it('returns correct results for boolean input', () => { + expect(shouldValidateOnChange('field', true)).toBe(true); + expect(shouldValidateOnChange('field', false)).toBe(false); + }); + + it('returns correct results for array input', () => { + expect(shouldValidateOnChange('field', ['field', 'other-field'])).toBe(true); + expect(shouldValidateOnChange('field', ['other-field', 'other-field-2'])).toBe(false); + }); + + it('returns correct results for fields with numbers', () => { + expect(shouldValidateOnChange('field2', ['field2', 'other-field'])).toBe(true); + expect(shouldValidateOnChange('other-field-2', ['field2', 'other-field-2'])).toBe(true); + }); + + it('correctly detects index reference', () => { + expect(shouldValidateOnChange('field.2.name', [`field.${FORM_INDEX}.name`])).toBe(true); + expect( + shouldValidateOnChange('field.2.name.4.id', [`field.${FORM_INDEX}.name.${FORM_INDEX}.id`]) + ).toBe(true); + expect( + shouldValidateOnChange('field.name.4.id', [`field.${FORM_INDEX}.name.${FORM_INDEX}`]) + ).toBe(false); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.ts b/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.ts new file mode 100644 index 000000000..13d2646c7 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/should-validate-on-change.ts @@ -0,0 +1,17 @@ +import { FORM_INDEX } from '../form-index'; + +export function shouldValidateOnChange(path: unknown, validateInputOnChange: boolean | unknown[]) { + if (!validateInputOnChange) { + return false; + } + + if (typeof validateInputOnChange === 'boolean') { + return validateInputOnChange; + } + + if (Array.isArray(validateInputOnChange)) { + return validateInputOnChange.includes((path as string).replace(/[.][0-9]/g, `.${FORM_INDEX}`)); + } + + return false; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.test.ts b/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.test.ts new file mode 100644 index 000000000..35bed836b --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.test.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { validateFieldValue } from './validate-field-value'; + +describe('@mantine/form/validate-field-value', () => { + it('validates root field with rules record', () => { + expect( + validateFieldValue('a', { a: (value) => (value === 1 ? 'error-a' : null) }, { a: 1, b: 2 }) + ).toStrictEqual({ hasError: true, error: 'error-a' }); + }); + + it('validates field with function', () => { + expect(validateFieldValue('a', () => ({ a: 'error-a' }), { a: 1, b: 2 })).toStrictEqual({ + hasError: true, + error: 'error-a' + }); + + expect(validateFieldValue('b', () => ({ a: 'error-a' }), { a: 1, b: 2 })).toStrictEqual({ + hasError: false, + error: null + }); + }); + + it('validates nested field with rules record', () => { + expect( + validateFieldValue( + 'a.b', + { a: { b: (value) => (value === 1 ? 'error-b' : null) } }, + { a: { b: 1 } } + ) + ).toStrictEqual({ hasError: true, error: 'error-b' }); + }); + + it('validates parent of nested field with rules record', () => { + expect( + validateFieldValue( + 'a', + { a: { b: (value) => (value === 1 ? 'error-b' : null) } }, + { a: [{ b: 2 }, { b: 1 }] } + ) + ).toStrictEqual({ hasError: true, error: 'error-b' }); + }); + + it('validates array field with rules record', () => { + expect( + validateFieldValue( + 'a.1.b', + { a: { b: (value) => (value === 1 ? 'error-b' : null) } }, + { a: [{ b: 2 }, { b: 1 }, { b: 3 }] } + ) + ).toStrictEqual({ hasError: true, error: 'error-b' }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.ts b/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.ts new file mode 100644 index 000000000..60b72e9ba --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/validate-field-value.ts @@ -0,0 +1,18 @@ +import type { FormValidateInput, FormFieldValidationResult } from '../types'; +import { validateValues } from './validate-values'; + +export function validateFieldValue( + path: unknown, + rules: FormValidateInput, + values: T +): FormFieldValidationResult { + if (typeof path !== 'string') { + return { hasError: false, error: null }; + } + + const results = validateValues(rules, values); + const pathInError = Object.keys(results.errors).find((errorKey) => + path.split('.').every((pathPart, i) => pathPart === errorKey.split('.')[i]) + ); + return { hasError: !!pathInError, error: pathInError ? results.errors[pathInError] : null }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validate/validate-values.test.ts b/packages/svelteui-core/src/components/Form/utils/validate/validate-values.test.ts new file mode 100644 index 000000000..3d50ea2ac --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/validate-values.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect, vitest } from 'vitest'; +import { validateValues } from './validate-values'; + +describe('@mantine/form/validate-values', () => { + it('returns correct results if form does not have any errors', () => { + expect( + validateValues( + { + a: (value) => (value === 1 ? null : 'error-a'), + b: (value) => (value === 1 ? null : 'error-b') + }, + { a: 1, b: 1 } + ) + ).toStrictEqual({ + hasErrors: false, + errors: {} + }); + }); + + it('validates values with function', () => { + expect(validateValues(() => ({ a: 'error-a', b: 'error-b' }), { a: 1, b: 2 })).toStrictEqual({ + hasErrors: true, + errors: { + a: 'error-a', + b: 'error-b' + } + }); + }); + + it('correctly handles empty errors with validate function', () => { + expect(validateValues(() => ({}), { a: 1, b: 2 })).toStrictEqual({ + hasErrors: false, + errors: {} + }); + }); + + it('calls validate function with values', () => { + const spy = vitest.fn(); + validateValues(spy, { a: 1, b: 2 }); + expect(spy).toHaveBeenCalledWith({ a: 1, b: 2 }); + }); + + it('validates values with rules record (root properties)', () => { + expect( + validateValues( + { + a: (value) => (value < 2 ? 'error-a' : null), + b: (value) => (value === '' ? null : 'error-b'), + c: (_value, values) => (values.b === '' ? 'error-c' : null) + }, + { a: 1, b: '', c: '' } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + a: 'error-a', + c: 'error-c' + } + }); + }); + + it('validates lists values with rules record (root properties)', () => { + expect( + validateValues( + { + a: (value) => (value < 2 ? 'error-a' : null), + b: (value) => (value.length === 0 ? 'error-b' : null), + c: (value) => (value.length === 0 ? 'error-c' : null) + }, + { a: 1, b: [], c: [1, 2, 3] } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + a: 'error-a', + b: 'error-b' + } + }); + }); + + it('validates values with rules record (within list)', () => { + expect( + validateValues( + { + a: (value) => (value < 2 ? 'error-a' : null), + b: { + c: (value) => (value < 2 ? 'error-c' : null), + d: (value) => (value === '' ? 'error-d' : null) + } + }, + { + a: 1, + b: [ + { c: 1, d: '' }, + { c: 2, d: '1' }, + { c: 1, d: '1' }, + { c: 3, d: '' } + ] + } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + a: 'error-a', + 'b.0.c': 'error-c', + 'b.0.d': 'error-d', + 'b.2.c': 'error-c', + 'b.3.d': 'error-d' + } + }); + }); + + it('validates values with rules record (within object)', () => { + expect( + validateValues( + { + a: { + b: (value) => (value === 1 ? 'error-b' : null), + c: { + d: (value) => (value === 1 ? 'error-d' : null) + } + } + }, + { a: { b: 1, c: { d: 1 } } } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + 'a.b': 'error-b', + 'a.c.d': 'error-d' + } + }); + }); + + it('validates nested lists correctly', () => { + expect( + validateValues( + { + a: { + b: { + c: (value) => (value < 2 ? 'error-c' : null) + } + } + }, + { a: [{ b: [{ c: 1 }, { c: 2 }] }, { b: [{ c: 3 }, { c: 1 }] }] } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + 'a.0.b.0.c': 'error-c', + 'a.1.b.1.c': 'error-c' + } + }); + }); + + it('validates mixed nested lists and objects', () => { + expect( + validateValues( + { + a: { + b: { + c: { + d: { + e: (value) => (value !== 1 ? 'error-e' : null) + } + } + } + } + }, + { + a: { + b: [{ c: { d: { e: 1 } } }, { c: { d: { e: 2 } } }] + } + } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + 'a.b.1.c.d.e': 'error-e' + } + }); + }); + + it('validates values based their path', () => { + expect( + validateValues( + { a: { b: { c: (_value, _values, path) => (path === 'a.b.0.c' ? 'error' : null) } } }, + { a: { b: [{ c: 1 }, { c: 2 }] } } + ) + ).toStrictEqual({ + hasErrors: true, + errors: { + 'a.b.0.c': 'error' + } + }); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validate/validate-values.ts b/packages/svelteui-core/src/components/Form/utils/validate/validate-values.ts new file mode 100644 index 000000000..079fc33e1 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validate/validate-values.ts @@ -0,0 +1,53 @@ +import { filterErrors } from '../filter-errors'; +import { getPath } from '../paths'; +import type { FormValidateInput, FormErrors, FormRulesRecord } from '../types'; + +function getValidationResults(errors: FormErrors) { + const filteredErrors = filterErrors(errors); + return { hasErrors: Object.keys(filteredErrors).length > 0, errors: filteredErrors }; +} + +function validateRulesRecord( + rules: FormRulesRecord, + values: T, + path = '', + errors: FormErrors = {} +) { + if (typeof rules !== 'object' || rules === null) { + return errors; + } + + return Object.keys(rules).reduce((acc, ruleKey) => { + const rule = rules[ruleKey]; + const rulePath = `${path === '' ? '' : `${path}.`}${ruleKey}`; + const value = getPath(rulePath, values); + let arrayValidation = false; + + if (typeof rule === 'function') { + acc[rulePath] = rule(value, values, rulePath); + } + + if (typeof rule === 'object' && Array.isArray(value)) { + arrayValidation = true; + value.forEach((_item, index) => + validateRulesRecord(rule, values, `${rulePath}.${index}`, acc) + ); + } + + if (typeof rule === 'object' && typeof value === 'object' && value !== null) { + if (!arrayValidation) { + validateRulesRecord(rule, values, rulePath, acc); + } + } + + return acc; + }, errors); +} + +export function validateValues(validate: FormValidateInput, values: T) { + if (typeof validate === 'function') { + return getValidationResults(validate(values)); + } + + return getValidationResults(validateRulesRecord(validate, values)); +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.test.ts new file mode 100644 index 000000000..7a09fbd3c --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect } from 'vitest'; +import { hasLength } from './has-length'; + +const TEST_ERROR = 'has-length-error'; + +describe('@mantine/form/hasLength', () => { + it('detects valid value', () => { + const numberValidator = hasLength(3, TEST_ERROR); + expect(numberValidator('hel')).toBe(null); + expect(numberValidator([1, 2, 3])).toBe(null); + expect(numberValidator({ length: 3 })).toBe(null); + + const minValidator = hasLength({ min: 2 }, TEST_ERROR); + expect(minValidator('test')).toBe(null); + expect(minValidator([1, 2])).toBe(null); + expect(minValidator({ length: 2 })).toBe(null); + + const maxValidator = hasLength({ max: 3 }, TEST_ERROR); + expect(maxValidator('tes')).toBe(null); + expect(maxValidator('te')).toBe(null); + expect(maxValidator([1, 2, 3])).toBe(null); + expect(maxValidator({ length: 2 })).toBe(null); + + const clampValidator = hasLength({ min: 1, max: 3 }, TEST_ERROR); + expect(clampValidator('t')).toBe(null); + expect(clampValidator('te')).toBe(null); + expect(clampValidator('tes')).toBe(null); + expect(clampValidator([1, 2])).toBe(null); + expect(clampValidator({ length: 3 })).toBe(null); + }); + + it('detects invalid value', () => { + const numberValidator = hasLength(3, TEST_ERROR); + expect(numberValidator('test')).toBe(TEST_ERROR); + expect(numberValidator([1, 2, 3, 4])).toBe(TEST_ERROR); + expect(numberValidator({ length: 5 })).toBe(TEST_ERROR); + expect(numberValidator(null)).toBe(TEST_ERROR); + expect(numberValidator(undefined)).toBe(TEST_ERROR); + expect(numberValidator({})).toBe(TEST_ERROR); + + const minValidator = hasLength({ min: 2 }, TEST_ERROR); + expect(minValidator('t')).toBe(TEST_ERROR); + expect(minValidator('')).toBe(TEST_ERROR); + expect(minValidator([1])).toBe(TEST_ERROR); + expect(minValidator({ length: 0 })).toBe(TEST_ERROR); + + const maxValidator = hasLength({ max: 3 }, TEST_ERROR); + expect(maxValidator('test')).toBe(TEST_ERROR); + expect(maxValidator('test-1')).toBe(TEST_ERROR); + expect(maxValidator([1, 2, 3, 4, 5])).toBe(TEST_ERROR); + expect(maxValidator({ length: 4 })).toBe(TEST_ERROR); + + const clampValidator = hasLength({ min: 1, max: 3 }, TEST_ERROR); + expect(clampValidator('test')).toBe(TEST_ERROR); + expect(clampValidator('')).toBe(TEST_ERROR); + expect(clampValidator([1, 2, 3, 4])).toBe(TEST_ERROR); + expect(clampValidator({ length: 0 })).toBe(TEST_ERROR); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.ts b/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.ts new file mode 100644 index 000000000..9bfa2e066 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/has-length/has-length.ts @@ -0,0 +1,43 @@ +import React from 'react'; + +interface HasLengthOptions { + max?: number; + min?: number; +} + +type HasLengthPayload = HasLengthOptions | number; + +function isLengthValid(payload: HasLengthPayload, value: any) { + if (typeof payload === 'number') { + return value.length === payload; + } + + const { max, min } = payload; + let valid = true; + + if (typeof max === 'number' && value.length > max) { + valid = false; + } + + if (typeof min === 'number' && value.length < min) { + valid = false; + } + + return valid; +} + +export function hasLength(payload: HasLengthPayload, error?: React.ReactNode) { + const _error = error || true; + + return (value: unknown) => { + if (typeof value === 'string') { + return isLengthValid(payload, value.trim()) ? null : _error; + } + + if (typeof value === 'object' && value !== null && 'length' in value) { + return isLengthValid(payload, value) ? null : _error; + } + + return _error; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/index.ts b/packages/svelteui-core/src/components/Form/utils/validators/index.ts new file mode 100644 index 000000000..63b93c6ee --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/index.ts @@ -0,0 +1,6 @@ +export { isNotEmpty } from './is-not-empty/is-not-empty'; +export { matches } from './matches/matches'; +export { isEmail } from './is-email/is-email'; +export { hasLength } from './has-length/has-length'; +export { isInRange } from './is-in-range/is-in-range'; +export { matchesField } from './matches-field/matches-field'; diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.test.ts new file mode 100644 index 000000000..070a44fee --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.test.ts @@ -0,0 +1,22 @@ +import { describe, it, expect } from 'vitest'; +import { isEmail } from './is-email'; + +const TEST_ERROR = 'email-error'; + +describe('@mantine/form/isEmail', () => { + it('detects incorrect email', () => { + const validator = isEmail(TEST_ERROR); + expect(validator('')).toBe(TEST_ERROR); + expect(validator('test')).toBe(TEST_ERROR); + expect(validator('test@test')).toBe(TEST_ERROR); + expect(validator('test@test.')).toBe(TEST_ERROR); + expect(validator('test@test.c')).toBe(TEST_ERROR); + }); + + it('detects correct email', () => { + const validator = isEmail(TEST_ERROR); + expect(validator('test@email.com')).toBe(null); + expect(validator('another@test.cn')).toBe(null); + expect(validator('another@test.party')).toBe(null); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.ts new file mode 100644 index 000000000..22fc05fbe --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-email/is-email.ts @@ -0,0 +1,6 @@ +import React from 'react'; +import { matches } from '../matches/matches'; + +export function isEmail(error?: React.ReactNode) { + return matches(/^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/, error); +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.test.ts new file mode 100644 index 000000000..daeef06ac --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from 'vitest'; +import { isInRange } from './is-in-range'; + +const TEST_ERROR = 'is-in-range-error'; + +describe('@mantine/form/isInRange', () => { + it('detects valid value', () => { + const minValidator = isInRange({ min: 2 }, TEST_ERROR); + expect(minValidator(2)).toBe(null); + expect(minValidator(3)).toBe(null); + + const maxValidator = isInRange({ max: 2 }, TEST_ERROR); + expect(maxValidator(1)).toBe(null); + expect(maxValidator(0)).toBe(null); + + const clampValidator = isInRange({ min: 2, max: 5 }, TEST_ERROR); + expect(clampValidator(4)).toBe(null); + }); + + it('detects invalid value', () => { + const minValidator = isInRange({ min: 2 }, TEST_ERROR); + expect(minValidator(0)).toBe(TEST_ERROR); + expect(minValidator(1)).toBe(TEST_ERROR); + expect(minValidator('2')).toBe(TEST_ERROR); + expect(minValidator(null)).toBe(TEST_ERROR); + expect(minValidator([])).toBe(TEST_ERROR); + + const maxValidator = isInRange({ max: 2 }, TEST_ERROR); + expect(maxValidator(5)).toBe(TEST_ERROR); + + const clampValidator = isInRange({ min: 2, max: 5 }, TEST_ERROR); + expect(clampValidator(8)).toBe(TEST_ERROR); + expect(clampValidator(0)).toBe(TEST_ERROR); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.ts new file mode 100644 index 000000000..d23c580c5 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-in-range/is-in-range.ts @@ -0,0 +1,28 @@ +import React from 'react'; + +interface IsInRangePayload { + min?: number; + max?: number; +} + +export function isInRange({ min, max }: IsInRangePayload, error?: React.ReactNode) { + const _error = error || true; + + return (value: unknown) => { + if (typeof value !== 'number') { + return _error; + } + + let valid = true; + + if (typeof min === 'number' && value < min) { + valid = false; + } + + if (typeof max === 'number' && value > max) { + valid = false; + } + + return valid ? null : _error; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.test.ts new file mode 100644 index 000000000..39266cc13 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.test.ts @@ -0,0 +1,27 @@ +import { describe, it, expect } from 'vitest'; +import { isNotEmpty } from './is-not-empty'; + +const TEST_ERROR = 'not-empty-error'; + +describe('@mantine/form/isNotEmpty', () => { + it('correctly detects empty values', () => { + const validator = isNotEmpty(TEST_ERROR); + expect(validator('')).toBe(TEST_ERROR); + expect(validator(' ')).toBe(TEST_ERROR); + expect(validator(null)).toBe(TEST_ERROR); + expect(validator(undefined)).toBe(TEST_ERROR); + expect(validator([])).toBe(TEST_ERROR); + expect(validator(false)).toBe(TEST_ERROR); + }); + + it('correctly detects non empty values', () => { + const validator = isNotEmpty(TEST_ERROR); + expect(validator('1')).toBe(null); + expect(validator(' 1 ')).toBe(null); + expect(validator([1, 2, 3])).toBe(null); + expect(validator(0)).toBe(null); + expect(validator(10)).toBe(null); + expect(validator(NaN)).toBe(null); + expect(validator(true)).toBe(null); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.ts b/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.ts new file mode 100644 index 000000000..72da9c858 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/is-not-empty/is-not-empty.ts @@ -0,0 +1,25 @@ +import React from 'react'; + +export function isNotEmpty(error?: React.ReactNode) { + const _error = error || true; + + return (value: unknown) => { + if (typeof value === 'string') { + return value.trim().length > 0 ? null : _error; + } + + if (Array.isArray(value)) { + return value.length > 0 ? null : _error; + } + + if (value === null || value === undefined) { + return _error; + } + + if (value === false) { + return _error; + } + + return null; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.test.ts new file mode 100644 index 000000000..2bf6597d4 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.test.ts @@ -0,0 +1,17 @@ +import { describe, it, expect } from 'vitest'; +import { matchesField } from './matches-field'; + +const TEST_ERROR = 'matches-field-error'; + +describe('@mantine/form/matches-field', () => { + it('correctly detects values that do not match', () => { + const validator = matchesField('testField', TEST_ERROR); + expect(validator('test-value', { testField: 'test-value-1' })).toBe(TEST_ERROR); + expect(validator(undefined, { testField2: 'test-value' })).toBe(TEST_ERROR); + }); + + it('correctly detects values that match', () => { + const validator = matchesField('testField', TEST_ERROR); + expect(validator('test-value', { testField: 'test-value' })).toBe(null); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.ts b/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.ts new file mode 100644 index 000000000..5681443cb --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/matches-field/matches-field.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +export function matchesField(field: string, error?: React.ReactNode) { + const _error = error || true; + + return (value: unknown, values: Record) => { + if (!values || !(field in values)) { + return _error; + } + + return value === values[field] ? null : _error; + }; +} diff --git a/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.test.ts b/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.test.ts new file mode 100644 index 000000000..cf2667e64 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.test.ts @@ -0,0 +1,19 @@ +import { describe, it, expect } from 'vitest'; +import { matches } from './matches'; + +const TEST_ERROR = 'matches-error'; + +describe('@mantine/form/matches', () => { + it('correctly detects values that do not match', () => { + const validator = matches(/^\d+$/, TEST_ERROR); + expect(validator('test')).toBe(TEST_ERROR); + expect(validator('12test')).toBe(TEST_ERROR); + expect(validator('test21')).toBe(TEST_ERROR); + }); + + it('correctly detects values that match', () => { + const validator = matches(/^\d+$/, TEST_ERROR); + expect(validator('1')).toBe(null); + expect(validator('1234')).toBe(null); + }); +}); diff --git a/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.ts b/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.ts new file mode 100644 index 000000000..e81160f31 --- /dev/null +++ b/packages/svelteui-core/src/components/Form/utils/validators/matches/matches.ts @@ -0,0 +1,13 @@ +import React from 'react'; + +export function matches(regexp: RegExp, error?: React.ReactNode) { + const _error = error || true; + + return (value: unknown) => { + if (typeof value !== 'string') { + return _error; + } + + return regexp.test(value) ? null : _error; + }; +} diff --git a/packages/svelteui-core/src/components/Input/Input.d.ts b/packages/svelteui-core/src/components/Input/Input.d.ts index 72a3bf2b1..586926a60 100644 --- a/packages/svelteui-core/src/components/Input/Input.d.ts +++ b/packages/svelteui-core/src/components/Input/Input.d.ts @@ -11,6 +11,7 @@ type InputElementType = | HTMLDataListElement; export interface InputBaseProps extends DefaultProps { + ariaDescribedby?: string; icon?: Component | HTMLOrSVGElement; iconWidth?: number; iconProps?: { size: number; color: 'currentColor' | string }; diff --git a/packages/svelteui-core/src/components/Input/Input.svelte b/packages/svelteui-core/src/components/Input/Input.svelte index e4f015ba5..2cc1201bd 100644 --- a/packages/svelteui-core/src/components/Input/Input.svelte +++ b/packages/svelteui-core/src/components/Input/Input.svelte @@ -28,6 +28,7 @@ size: $$Props['size'] = 'sm', value: $$Props['value'] = '', invalid: $$Props['invalid'] = false, + ariaDescribedby: $$Props['ariaDescribedby'] = undefined, multiline: $$Props['multiline'] = false, autocomplete: $$Props['autocomplete'] = 'on', type: $$Props['type'] = 'text', @@ -129,7 +130,8 @@ Base component to create custom inputs {placeholder} {autocomplete} {autofocus} - aria-invalid={invalid} + aria-invalid={invalid || undefined} + aria-describedby={ariaDescribedby} class:withIcon={icon} class={cx( className, @@ -155,7 +157,8 @@ Base component to create custom inputs {autocomplete} {type} {autofocus} - aria-invalid={invalid} + aria-invalid={invalid || undefined} + aria-describedby={ariaDescribedby} class:disabled class:invalid class:withIcon={icon} @@ -178,7 +181,8 @@ Base component to create custom inputs bind:element bind:value use={[forwardEvents, [useActions, use]]} - aria-invalid={invalid} + aria-invalid={invalid || undefined} + aria-describedby={ariaDescribedby} class={cx( className, { diff --git a/packages/svelteui-core/src/components/InputWrapper/InputWrapper.svelte b/packages/svelteui-core/src/components/InputWrapper/InputWrapper.svelte index 82f88b6ef..78ffd7110 100644 --- a/packages/svelteui-core/src/components/InputWrapper/InputWrapper.svelte +++ b/packages/svelteui-core/src/components/InputWrapper/InputWrapper.svelte @@ -23,10 +23,6 @@ size: $$Props['size'] = 'sm'; export { className as class }; - let _labelProps; - $: { - _labelProps = labelElement === 'label' ? { htmlFor: id, ...labelProps } : { ...labelProps }; - } $: ({ cx, classes, getStyles } = useStyles({ size }, { name: 'InputWrapper' })); @@ -37,7 +33,7 @@ {...$$restProps} > {#if label} - + {/if} {#if description} diff --git a/packages/svelteui-core/src/components/Text/Text.svelte b/packages/svelteui-core/src/components/Text/Text.svelte index c4808e9cf..253721d57 100644 --- a/packages/svelteui-core/src/components/Text/Text.svelte +++ b/packages/svelteui-core/src/components/Text/Text.svelte @@ -88,7 +88,7 @@ Display text and links with theme styles. bind:element use={[forwardEvents, [useActions, use]]} class={cx(className, classes.root, getStyles({ css: override }))} - href={href ?? undefined} + href={href || undefined} {...$$restProps} > diff --git a/packages/svelteui-core/src/components/TextInput/TextInput.svelte b/packages/svelteui-core/src/components/TextInput/TextInput.svelte index 9d1ce13f0..5c80aed07 100644 --- a/packages/svelteui-core/src/components/TextInput/TextInput.svelte +++ b/packages/svelteui-core/src/components/TextInput/TextInput.svelte @@ -36,6 +36,7 @@ const _showRightSection = showRightSection === undefined ? !!$$slots.rightSection : showRightSection; $: _invalid = invalid || !!error; + $: _errorProps = { ...errorProps, id: (errorProps.id as string) || randomID('input-error') };