@ahrowe/ui 0.1.16 → 0.1.17

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.
@@ -13,6 +13,8 @@ interface UseColumnsArgs<T> {
13
13
  startIndex: number;
14
14
  endIndex: number;
15
15
  containerHeight: number;
16
+ /** Re-measure `fit` columns when the container width settles (e.g. after a route transition). */
17
+ containerWidth: number;
16
18
  }
17
19
  interface UseColumnsResult<T> {
18
20
  hasColumns: boolean;
@@ -33,5 +35,5 @@ interface UseColumnsResult<T> {
33
35
  * `fit`-column width measurement that pins one shared px track across the header
34
36
  * and every row grid so they stay aligned.
35
37
  */
36
- export declare function useColumns<T>({ columns, items, multiSelect, visibleColumnKeys, onVisibleColumnsChange, persistColumnsKey, showColumnToggle, rootRef, startIndex, endIndex, containerHeight, }: UseColumnsArgs<T>): UseColumnsResult<T>;
38
+ export declare function useColumns<T>({ columns, items, multiSelect, visibleColumnKeys, onVisibleColumnsChange, persistColumnsKey, showColumnToggle, rootRef, startIndex, endIndex, containerHeight, containerWidth, }: UseColumnsArgs<T>): UseColumnsResult<T>;
37
39
  export {};
@@ -22,6 +22,7 @@ interface UseVirtualWindowResult {
22
22
  offsets: number[];
23
23
  totalHeight: number;
24
24
  containerHeight: number;
25
+ containerWidth: number;
25
26
  startIndex: number;
26
27
  endIndex: number;
27
28
  handleScroll: (e: React.UIEvent<HTMLDivElement>) => void;
package/docs/CLAUDE.md CHANGED
@@ -37,7 +37,7 @@ const myTheme: Theme = {
37
37
 
38
38
  ## FormValidator (for form-connected components)
39
39
 
40
- Input, NumberInput, Dropdown, Checkbox all accept a `formValidator` prop. Use it to connect validation state.
40
+ Input, NumberInput, Dropdown, Checkbox, DatePicker, IconPicker, InputDropdown, and Textarea all accept a `formValidator` prop. Use it to connect validation state.
41
41
 
42
42
  ```tsx
43
43
  import { FormValidator, Validators, Input } from '@ahrowe/ui';
@@ -47,38 +47,29 @@ const nameValidator = new FormValidator('', [Validators.required(), Validators.m
47
47
 
48
48
  // Pass to component — it tracks value, dirty, touched, errors internally
49
49
  <Input label="Name" formValidator={nameValidator} />
50
-
51
- // Read state
52
- nameValidator.value // current value
53
- nameValidator.hasError() // true if any error
54
- nameValidator.getCurrentErrorMessage() // first error message or null
55
- nameValidator.touched // user has blurred the field
56
- nameValidator.dirty // value has changed
57
-
58
- // Validators available
59
- Validators.required()
60
- Validators.minLength(n)
61
- Validators.maxLength(n)
62
- Validators.email()
63
- Validators.mustBeNumber()
64
- Validators.date()
65
- Validators.website()
66
- Validators.password() // min 8 chars, 1 letter, 1 digit
67
- Validators.oneOf([...])
68
- Validators.multiEmail()
69
- Validators.bic()
70
50
  ```
71
51
 
72
- For groups of fields:
52
+ For multi-field forms with a single submit handler, use `useFormValidatorGroup`:
73
53
 
74
54
  ```tsx
75
- import { FormValidatorGroup, useFormValidatorGroup } from '@ahrowe/ui';
55
+ import { useFormValidatorGroup, FormValidator, Validators } from '@ahrowe/ui';
56
+
57
+ const form = useFormValidatorGroup(
58
+ {
59
+ name: new FormValidator('', [Validators.required()]),
60
+ email: new FormValidator('', [Validators.required(), Validators.email()]),
61
+ },
62
+ { onSubmit: async (values) => save(values) }, // values is fully typed
63
+ );
76
64
 
77
- const group = new FormValidatorGroup([nameValidator, emailValidator]);
78
- group.isValid() // all fields valid
79
- group.validate() // trigger all validators
65
+ <form.Form>
66
+ <Input label="Name" formValidator={form.group.name} />
67
+ <Input label="Email" formValidator={form.group.email} />
68
+ </form.Form>
80
69
  ```
81
70
 
71
+ Full reference: see **FormValidator.md** (single field, all built-in validators, custom validators) and **FormValidatorGroup.md** (whole-form submit, validate, reset, server-error mapping) below.
72
+
82
73
  ## Slot customisation (classNames / styles)
83
74
 
84
75
  Most components expose `classNames` and `styles` props for styling inner elements without fighting CSS specificity.
@@ -116,6 +107,8 @@ Slot keys per component are documented in each component's doc file below.
116
107
  @EmptyState.md
117
108
  @Fab.md
118
109
  @FloatingMenu.md
110
+ @FormValidator.md
111
+ @FormValidatorGroup.md
119
112
  @IconPicker.md
120
113
  @IdleManager.md
121
114
  @InfiniteBlock.md
@@ -0,0 +1,146 @@
1
+ # FormValidator
2
+
3
+ **When to use:** Track and validate a single form field's value, dirty/touched state, and errors. Pass the instance to any form-connected component (`Input`, `NumberInput`, `Dropdown`, `Checkbox`, `DatePicker`, `IconPicker`, `InputDropdown`, `Textarea`) via its `formValidator` prop — the component then reads the value and surfaces validation errors automatically. For multi-field forms with submit handling, use `FormValidatorGroup` instead.
4
+
5
+ **Import:** `import { FormValidator, Validators, useFormValidator } from '@ahrowe/ui'`
6
+ **Types:** `import type { ValidatorFunction, ValidationError } from '@ahrowe/ui'`
7
+
8
+ ## Basic usage
9
+
10
+ ```tsx
11
+ import { FormValidator, Validators, Input } from '@ahrowe/ui';
12
+
13
+ // Create with a default (empty) value and an optional list of validators
14
+ const nameValidator = new FormValidator('', [Validators.required(), Validators.minLength(2)]);
15
+
16
+ // Hand it to a form component — it tracks value, dirty, touched, and errors internally
17
+ <Input label="Name" formValidator={nameValidator} />
18
+
19
+ // Read state at any time
20
+ nameValidator.value // current value (typed as T)
21
+ nameValidator.hasError() // true if any error is present
22
+ nameValidator.hasError('required') // true if the error with id 'required' is present
23
+ nameValidator.getCurrentErrorMessage() // first error message, or null
24
+ nameValidator.touched // user has entered and left the field
25
+ nameValidator.dirty // value has changed since creation
26
+ ```
27
+
28
+ > **The first constructor argument is the field's _default (empty)_ value** — what the field holds when nothing has been entered, **not** an existing record's value. Use the type's natural empty value:
29
+ >
30
+ > | Field | Default value |
31
+ > |-------|---------------|
32
+ > | string (text, email, …) | `''` |
33
+ > | `Date` | `null` |
34
+ > | number | `undefined` (or `0`) |
35
+ > | checkbox / boolean | `false` |
36
+ > | single-select id | `null` |
37
+ >
38
+ > To pre-fill fields when **editing an existing entity**, don't seed values here — pass them as `initialValues` on a `FormValidatorGroup` (see [FormValidatorGroup.md](FormValidatorGroup.md)). That keeps the empty value as the validator's true default so `reset()` works correctly.
39
+
40
+ ## Re-rendering a function component when the value changes
41
+
42
+ A `FormValidator` lives outside React state, so changing it does **not** by itself re-render a function component. When you read `validator.value` / `validator.hasError()` directly in render (rather than only handing the instance to a form component), subscribe with `useFormValidator` so the component re-renders on every change:
43
+
44
+ ```tsx
45
+ import { useMemo } from 'react';
46
+ import { FormValidator, Validators, useFormValidator, Input } from '@ahrowe/ui';
47
+
48
+ function NameField() {
49
+ // Create once — never `new FormValidator(...)` inline in render without memoising
50
+ const validator = useMemo(() => new FormValidator('', [Validators.required()]), []);
51
+ useFormValidator(validator); // re-renders this component on value/error/touched/dirty changes
52
+
53
+ return (
54
+ <>
55
+ <Input label="Name" formValidator={validator} />
56
+ <button disabled={validator.hasError()}>Save</button>
57
+ </>
58
+ );
59
+ }
60
+ ```
61
+
62
+ `useFormValidator(validator)` returns the same instance for convenience and accepts `null`.
63
+
64
+ ## Typing the value
65
+
66
+ `FormValidator<T>` is generic. Pass the value type for non-string fields, and give each its natural empty value as the default:
67
+
68
+ ```tsx
69
+ const ageValidator = new FormValidator<number | undefined>(undefined, [Validators.required()]);
70
+ const dateValidator = new FormValidator<Date | null>(null);
71
+ const agreedValidator = new FormValidator<boolean>(false, [Validators.required()]);
72
+ ```
73
+
74
+ ## Instance API
75
+
76
+ | Member | Signature | Description |
77
+ |--------|-----------|-------------|
78
+ | `value` | `T` (get/set) | Current value. The setter assigns **without** validating — prefer `set()` for user input. |
79
+ | `set` | `(newValue: T, wasUser = true) => this` | Assign value, mark dirty when `wasUser`, then re-validate. Chainable. |
80
+ | `validate` | `() => void` | Clear errors and run every validator against the current value. |
81
+ | `touched` | `boolean` (get/set) | User has entered and left the field. |
82
+ | `dirty` | `boolean` (get/set) | Value changed since creation. |
83
+ | `errors` | `ValidationError[]` (get) | All current errors (`{ id, message }`). |
84
+ | `hasError` | `(id?: string) => boolean` | Any error when called with no arg; a specific error by `id` otherwise. |
85
+ | `getCurrentErrorMessage` | `() => string \| null` | First error message, or `null`. |
86
+ | `addError` | `(error: ValidationError) => void` | Push an error manually (e.g. a server-side error). |
87
+ | `removeErrors` | `(ids: string[]) => void` | Remove errors by id. |
88
+ | `clearErrors` | `() => void` | Remove all errors. |
89
+ | `label` | `string` (get/set) | Field label; form components set this and validators receive it (used by `required()` messages). |
90
+ | `validators` | `ValidatorFunction[]` | The active validator list — mutable. |
91
+ | `registerOnUpdateListener` | `(cb: () => void) => void` | Subscribe to changes (what `useFormValidator` uses internally). |
92
+ | `removeOnUpdateListener` | `(cb: () => void) => void` | Unsubscribe. |
93
+
94
+ > Validators run once in the constructor, so `hasError()` reflects the initial value immediately.
95
+
96
+ ## Built-in validators
97
+
98
+ All live as static methods on `Validators`. Each returns a `ValidatorFunction` and accepts an optional custom `errorMsg` as the last argument to override the default message.
99
+
100
+ ```tsx
101
+ Validators.required(errorMsg?) // value must be present (0 counts as present)
102
+ Validators.minLength(min, errorMsg?) // string length >= min (also fails on empty)
103
+ Validators.maxLength(max, errorMsg?) // string length <= max
104
+ Validators.email(errorMsg?) // valid email address
105
+ Validators.multiEmail(errorMsg?) // comma/semicolon-separated list of emails
106
+ Validators.mustBeNumber(errorMsg?) // parses as a number
107
+ Validators.oneOf(allowedValues, errorMsg?) // value is in the allowed array
108
+ Validators.typeOf(allowedTypes, errorMsg?) // typeof value matches (string or string[])
109
+ Validators.date(errorMsg?) // valid date string (DD.MM.YYYY or ISO)
110
+ Validators.dateObject(errorMsg?) // an object with an `isValid` flag (e.g. a date wrapper)
111
+ Validators.website(errorMsg?) // valid URL
112
+ Validators.bic(errorMsg?) // valid BIC/SWIFT code
113
+ Validators.minLetters(min, errorMsg?) // at least `min` letters (a–z, incl. äöüß)
114
+ Validators.minDigits(min, errorMsg?) // at least `min` digits
115
+ Validators.password(errorMsg?) // min 8 chars, ≥1 letter, ≥1 digit
116
+ ```
117
+
118
+ > **Empty values pass most validators.** Except `required()` and `minLength()`, every validator returns `null` for an empty value — so a field is "valid when empty" unless you also add `Validators.required()`. Combine them:
119
+ > `new FormValidator('', [Validators.required(), Validators.email()])`.
120
+
121
+ ## Custom validators
122
+
123
+ A validator is any `(value, label?) => ValidationError | null` — return `null` when valid, or `{ id, message }` when not:
124
+
125
+ ```tsx
126
+ import type { ValidatorFunction } from '@ahrowe/ui';
127
+
128
+ const mustBeEven: ValidatorFunction = (value) =>
129
+ Number(value) % 2 === 0 ? null : { id: 'even', message: 'Must be an even number' };
130
+
131
+ const validator = new FormValidator<number>(0, [Validators.required(), mustBeEven]);
132
+ ```
133
+
134
+ The `id` you choose is what `hasError('even')` and `removeErrors(['even'])` match against.
135
+
136
+ ## Reading server-side errors back into a field
137
+
138
+ ```tsx
139
+ validator.addError({ id: 'taken', message: 'This email is already registered' });
140
+ // later, once the user edits the field again:
141
+ validator.removeErrors(['taken']);
142
+ ```
143
+
144
+ When validating a whole form at once, prefer `FormValidatorGroup.parseError()` — see `FormValidatorGroup`.
145
+ </content>
146
+ </invoke>
@@ -0,0 +1,154 @@
1
+ # FormValidatorGroup
2
+
3
+ **When to use:** Manage a whole form — multiple fields, a single submit handler, validate-on-submit, dirty/touched tracking across the form, and mapping server-side errors back onto the right fields. Build it from a map of named `FormValidator` instances. For a single field, use `FormValidator` on its own.
4
+
5
+ **Import:** `import { useFormValidatorGroup, FormValidatorGroup, FormValidator, Validators } from '@ahrowe/ui'`
6
+ **Types:** `import type { FormValidatorGroupOptions, InferGroupValues, MergedValues } from '@ahrowe/ui'`
7
+
8
+ ## Recommended: the `useFormValidatorGroup` hook
9
+
10
+ In function components, create the group with the hook. It instantiates the group once on mount, keeps `onSubmit`/`onValidate` fresh via refs, and re-renders the component whenever any field, the submit state, or validation state changes.
11
+
12
+ ```tsx
13
+ import { useFormValidatorGroup, FormValidator, Validators, Input, InputType, ActionButtons } from '@ahrowe/ui';
14
+
15
+ function ProfileForm({ user, onSaved }) {
16
+ const form = useFormValidatorGroup(
17
+ {
18
+ // Each FormValidator's first arg is the DEFAULT (empty) value, not the user's value
19
+ name: new FormValidator('', [Validators.required(), Validators.minLength(2)]),
20
+ email: new FormValidator('', [Validators.required(), Validators.email()]),
21
+ },
22
+ {
23
+ // initialValues OVERRIDES those defaults with the existing record you're editing
24
+ initialValues: { name: user.name, email: user.email },
25
+ onSubmit: async (values) => {
26
+ // `values` is fully typed: { name: string; email: string }
27
+ await api.updateProfile(values);
28
+ onSaved();
29
+ },
30
+ },
31
+ );
32
+
33
+ // `form.Form` wires up onSubmit + noValidate for you
34
+ return (
35
+ <form.Form>
36
+ <Input label="Name" formValidator={form.group.name} />
37
+ <Input label="Email" type={InputType.Email} formValidator={form.group.email} />
38
+ <ActionButtons
39
+ submitLabel="Save"
40
+ onSubmit={form.submit}
41
+ isSubmitButtonLoading={form.isSubmitting}
42
+ isSubmitButtonDisabled={!form.isDirty}
43
+ />
44
+ </form.Form>
45
+ );
46
+ }
47
+ ```
48
+
49
+ > The exported `FormValidatorGroup` is the form-aware subclass: every instance has a `Form` component (`form.Form`) that renders `<form onSubmit={form.submit} noValidate>`. You can also call `form.submit(event)` directly from a button.
50
+
51
+ > **Defaults vs. `initialValues` — the editing pattern.** Each `FormValidator`'s first argument is the field's _empty default_ (`''`, `null`, `false`, …). When you load an existing entity to **edit** it (e.g. an existing user), put those current values in `initialValues` — not in the validator constructors. The group seeds each field from `initialValues` on creation, and that same set becomes the baseline `reset()` restores to. Keep the constructors as empty defaults so a "create new" form (no `initialValues`) and an "edit" form share the exact same field definitions.
52
+
53
+ ```tsx
54
+ // Create form — no initialValues, fields stay at their empty defaults
55
+ const createForm = useFormValidatorGroup(fields, { onSubmit: create });
56
+
57
+ // Edit form — same `fields`, current record passed as initialValues
58
+ const editForm = useFormValidatorGroup(fields, { initialValues: user, onSubmit: update });
59
+ ```
60
+
61
+ ## Submit flow
62
+
63
+ `submit(event?)` does the following:
64
+
65
+ 1. `preventDefault` / `stopPropagation` on the event if present.
66
+ 2. Sets `isSubmitting` true.
67
+ 3. On the **first** submit, runs `validate()` (validates every field + marks them touched); on later submits, runs `onValidate` only.
68
+ 4. If any field error or a custom validation error exists → calls `onSubmitError` with it, increments `submitCount`, and stops (does **not** call `onSubmit`).
69
+ 5. Otherwise calls `onSubmit(values, group)`. If it throws, the error is passed to `parseError(err, true)` to surface field-level or generic errors.
70
+ 6. Sets `isSubmitting` false.
71
+
72
+ By default `onSubmitError` shows the first error as an error **toast** (requires a `ToastProvider` in the tree — see `Toast`). Override it via `options.onSubmitError`.
73
+
74
+ ## Options
75
+
76
+ ```ts
77
+ interface FormValidatorGroupOptions {
78
+ initialValues?: Record<string, unknown>; // OVERRIDES the validators' empty defaults with an
79
+ // existing record's values (use when editing); also
80
+ // becomes the baseline reset() restores to
81
+ onSubmit?: (values, group) => void | Promise<void>;
82
+ onValidate?: (group) => ValidationError | null | void; // extra cross-field validation
83
+ errorParser?: (err: unknown) => ValidationError[]; // turn an API error into field errors
84
+ onSubmitError?: (errors: ValidationError[]) => void; // default: error toast of the first message
85
+ }
86
+ ```
87
+
88
+ `onValidate` runs as part of `submit` — return a `{ id, message }` to block submission with a form-level error (e.g. "passwords must match"):
89
+
90
+ ```tsx
91
+ useFormValidatorGroup(
92
+ { password: new FormValidator(''), confirm: new FormValidator('') },
93
+ {
94
+ onValidate: (group) =>
95
+ group.group.password.value === group.group.confirm.value
96
+ ? null
97
+ : { id: 'mismatch', message: 'Passwords do not match' },
98
+ onSubmit: async (values) => api.setPassword(values),
99
+ },
100
+ );
101
+ ```
102
+
103
+ ## Instance API
104
+
105
+ | Member | Signature | Description |
106
+ |--------|-----------|-------------|
107
+ | `group` | `Record<name, FormValidator>` | The field validators. Access a field via `form.group.<name>`. |
108
+ | `Form` | `React.FC<FormHTMLAttributes>` | `<form>` pre-wired with `onSubmit={submit}` + `noValidate`. |
109
+ | `submit` | `(event?) => Promise<void>` | Validate then run `onSubmit`. Safe to pass straight to a `<form onSubmit>` or button. |
110
+ | `validate` | `() => ValidationError \| null \| void` | Validate every field, mark all touched, run `onValidate`. |
111
+ | `getValues` | `() => MergedValues` | Typed snapshot: `initialValues` merged with every field's current value. |
112
+ | `set` | `(name, value) => this` | Set one field's value (validates it). Chainable. |
113
+ | `setValidator` | `(name, validator) => this` | Swap a field's `FormValidator` instance. |
114
+ | `reset` | `(overrides?) => void` | Restore fields to their post-creation values (or `overrides`), clear dirty/touched, fire `onReset`. |
115
+ | `resetDirtyAndTouched` | `() => void` | Clear dirty/touched on all fields without changing values. |
116
+ | `parseError` | `(err, isInternal?) => void` | Run `errorParser` and push field errors onto matching validators; otherwise a generic error. |
117
+ | `getFormError` | `() => ValidationError \| null` | First error across all fields, or `null`. |
118
+ | `isSubmitting` | `boolean` (get) | True while `submit` is running. |
119
+ | `isDirty` | `boolean` (get) | Any field changed since creation. |
120
+ | `wasTouched` | `boolean` (get) | Any field entered and left. |
121
+ | `hasErrors` | `boolean` (get) | Any field currently has an error. |
122
+ | `initialValues` | `object` (get) | Copy of the initial values. |
123
+ | `submitCount` | `number` | How many times `submit` has run (reset to 0 on any field change). |
124
+ | `onDebounce` | `(values) => void` | Assignable callback fired 500ms after the last field change. |
125
+ | `onReset` | `(group, valuesAfterCreation, initialValues) => void` | Assignable, fired by `reset()`. |
126
+
127
+ ## Mapping API errors back to fields
128
+
129
+ If your API returns errors shaped like `{ errors: [{ <fieldName>: '<message>' }] }`, the default `errorParser` routes each message to the matching field. A thrown `onSubmit` error is parsed automatically; you can also call it manually:
130
+
131
+ ```tsx
132
+ try {
133
+ await api.save(form.getValues());
134
+ } catch (err) {
135
+ form.parseError(err); // pushes e.g. an "email already taken" error onto form.group.email
136
+ }
137
+ ```
138
+
139
+ Pass a custom `errorParser` in the options when your API uses a different shape.
140
+
141
+ ## Class components
142
+
143
+ For legacy class components, instantiate directly and pass the component instance so the group can call `forceUpdate()`:
144
+
145
+ ```tsx
146
+ this.form = new FormValidatorGroup(
147
+ { name: new FormValidator('', [Validators.required()]) },
148
+ this, // component instance — enables re-render via forceUpdate
149
+ { onSubmit: (values) => this.save(values) },
150
+ );
151
+ ```
152
+
153
+ `ValidatableComponent` (also exported) is the base class the built-in form components extend — subclass it when building your own validator-aware input.
154
+ </content>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ahrowe/ui",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },