form-father 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -510,7 +510,11 @@ function dateOrder(startField, endField, message = 'Дата окончания
510
510
  }, { field: endField, rule, message });
511
511
  }
512
512
  let loaderIdCounter = 0;
513
+ let errorIdCounter = 0;
513
514
  const FORM_INSTANCES = new WeakMap();
515
+ const ERROR_DESCRIBEDBY_ID_ATTRIBUTE = 'data-form-father-describedby-id';
516
+ const ADDED_DESCRIBEDBY_ID_ATTRIBUTE = 'data-form-father-added-describedby-id';
517
+ const ERROR_SUMMARY_ATTRIBUTE = 'data-form-father-summary';
514
518
  /** Изначальная дефолтная схема. */
515
519
  const INITIAL_DEFAULT_SCHEMA = {
516
520
  tel: {
@@ -595,6 +599,9 @@ class Form {
595
599
  validationDebounce: 0,
596
600
  errorContainerAttribute: 'data-error-container',
597
601
  validationStateAttribute: 'data-form-father-state',
602
+ ariaDescribeErrors: true,
603
+ errorIdPrefix: 'form-father-error',
604
+ errorSummary: false,
598
605
  observeMutations: false,
599
606
  };
600
607
  /* Слияние параметров: глобальные параметры → пользовательские параметры */
@@ -699,6 +706,7 @@ class Form {
699
706
  this.clearFieldValidationState($field);
700
707
  });
701
708
  this.hideErrorForm();
709
+ this.hideErrorSummary();
702
710
  return this;
703
711
  }
704
712
  isErrorResponse(issue) {
@@ -763,6 +771,7 @@ class Form {
763
771
  this.normalizeValidationIssues(issues, source).forEach(issue => {
764
772
  this.applyValidationIssue(issue, source);
765
773
  });
774
+ this.syncErrorSummary();
766
775
  return this;
767
776
  }
768
777
  validateFormValidators() {
@@ -931,6 +940,52 @@ class Form {
931
940
  return null;
932
941
  }
933
942
  }
943
+ toSafeIdPart(value) {
944
+ return (value
945
+ .trim()
946
+ .toLowerCase()
947
+ .replace(/[^a-z0-9_-]+/gi, '-')
948
+ .replace(/^-+|-+$/g, '') || 'field');
949
+ }
950
+ createErrorId($input) {
951
+ const prefix = this.config.errorIdPrefix || 'form-father-error';
952
+ return `${this.toSafeIdPart(prefix)}-${this.toSafeIdPart(this.getFieldKey($input))}-${++errorIdCounter}`;
953
+ }
954
+ ensureElementId($input, $error) {
955
+ if (!$error.id) {
956
+ $error.id = $input.getAttribute(ERROR_DESCRIBEDBY_ID_ATTRIBUTE) || this.createErrorId($input);
957
+ }
958
+ return $error.id;
959
+ }
960
+ addErrorDescription($input, $error) {
961
+ if (!$error.hasAttribute('role'))
962
+ $error.setAttribute('role', 'alert');
963
+ const errorId = this.ensureElementId($input, $error);
964
+ $input.setAttribute(ERROR_DESCRIBEDBY_ID_ATTRIBUTE, errorId);
965
+ if (this.config.ariaDescribeErrors === false)
966
+ return;
967
+ const currentIds = ($input.getAttribute('aria-describedby') || '').split(/\s+/).filter(Boolean);
968
+ if (!currentIds.includes(errorId)) {
969
+ $input.setAttribute('aria-describedby', [...currentIds, errorId].join(' '));
970
+ $input.setAttribute(ADDED_DESCRIBEDBY_ID_ATTRIBUTE, errorId);
971
+ }
972
+ }
973
+ removeErrorDescription($input) {
974
+ const addedId = $input.getAttribute(ADDED_DESCRIBEDBY_ID_ATTRIBUTE);
975
+ if (addedId) {
976
+ const nextIds = ($input.getAttribute('aria-describedby') || '')
977
+ .split(/\s+/)
978
+ .filter(id => id && id !== addedId);
979
+ if (nextIds.length > 0) {
980
+ $input.setAttribute('aria-describedby', nextIds.join(' '));
981
+ }
982
+ else {
983
+ $input.removeAttribute('aria-describedby');
984
+ }
985
+ }
986
+ $input.removeAttribute(ERROR_DESCRIBEDBY_ID_ATTRIBUTE);
987
+ $input.removeAttribute(ADDED_DESCRIBEDBY_ID_ATTRIBUTE);
988
+ }
934
989
  /**
935
990
  * Показывает ошибку для поля ввода.
936
991
  *
@@ -942,8 +997,7 @@ class Form {
942
997
  const $errorContainer = this.getErrorContainer($input);
943
998
  if ($errorContainer) {
944
999
  $errorContainer.textContent = text;
945
- if (!$errorContainer.hasAttribute('role'))
946
- $errorContainer.setAttribute('role', 'alert');
1000
+ this.addErrorDescription($input, $errorContainer);
947
1001
  $errorContainer.removeAttribute('hidden');
948
1002
  return;
949
1003
  }
@@ -955,6 +1009,9 @@ class Form {
955
1009
  const customShowError = $inputWrapper.showError;
956
1010
  if (typeof customShowError === 'function') {
957
1011
  customShowError.call($inputWrapper, text);
1012
+ const $customError = $inputWrapper.querySelector('[data-form-father-error]');
1013
+ if ($customError)
1014
+ this.addErrorDescription($input, $customError);
958
1015
  return;
959
1016
  }
960
1017
  $inputWrapper.classList.add('input__wrapper--error');
@@ -966,6 +1023,7 @@ class Form {
966
1023
  $input.insertAdjacentElement('afterend', $error);
967
1024
  }
968
1025
  $error.textContent = text;
1026
+ this.addErrorDescription($input, $error);
969
1027
  }
970
1028
  /** Показывает лоадер */
971
1029
  showLoader() {
@@ -1024,6 +1082,7 @@ class Form {
1024
1082
  const $inputWrapper = closest($input, this.config.inputWrapperSelector) || $input.parentElement;
1025
1083
  const $errorContainer = this.getErrorContainer($input);
1026
1084
  $input.removeAttribute('aria-invalid');
1085
+ this.removeErrorDescription($input);
1027
1086
  if ($errorContainer) {
1028
1087
  $errorContainer.textContent = '';
1029
1088
  $errorContainer.setAttribute('hidden', '');
@@ -1034,6 +1093,125 @@ class Form {
1034
1093
  $inputWrapper.getAttribute('data-type-error') || 'default';
1035
1094
  }
1036
1095
  }
1096
+ getErrorSummaryOptions() {
1097
+ if (!this.config.errorSummary)
1098
+ return null;
1099
+ return this.config.errorSummary === true ? {} : this.config.errorSummary;
1100
+ }
1101
+ getErrorSummaryContainer(options, create) {
1102
+ if (options.selector) {
1103
+ try {
1104
+ return (this.$el.querySelector(options.selector) ||
1105
+ document.querySelector(options.selector));
1106
+ }
1107
+ catch (_a) {
1108
+ console.warn(`[FormFather] Invalid error summary selector "${options.selector}"`);
1109
+ return null;
1110
+ }
1111
+ }
1112
+ const existing = this.$el.querySelector(`[${ERROR_SUMMARY_ATTRIBUTE}]`);
1113
+ if (existing || !create)
1114
+ return existing;
1115
+ const $summary = document.createElement('div');
1116
+ $summary.setAttribute(ERROR_SUMMARY_ATTRIBUTE, '');
1117
+ this.$el.insertAdjacentElement('afterbegin', $summary);
1118
+ return $summary;
1119
+ }
1120
+ prepareErrorSummaryContainer($summary) {
1121
+ $summary.setAttribute(ERROR_SUMMARY_ATTRIBUTE, '');
1122
+ if (!$summary.hasAttribute('role'))
1123
+ $summary.setAttribute('role', 'alert');
1124
+ if (!$summary.hasAttribute('tabindex'))
1125
+ $summary.setAttribute('tabindex', '-1');
1126
+ $summary.removeAttribute('hidden');
1127
+ }
1128
+ focusErrorSummary($summary) {
1129
+ try {
1130
+ $summary.focus({ preventScroll: true });
1131
+ }
1132
+ catch (_a) {
1133
+ $summary.focus();
1134
+ }
1135
+ }
1136
+ focusFieldFromSummary($field) {
1137
+ if (typeof $field.scrollIntoView === 'function') {
1138
+ $field.scrollIntoView({ behavior: 'smooth', block: 'center' });
1139
+ }
1140
+ try {
1141
+ $field.focus({ preventScroll: true });
1142
+ }
1143
+ catch (_a) {
1144
+ $field.focus();
1145
+ }
1146
+ }
1147
+ renderDefaultErrorSummary($summary, errors, options) {
1148
+ $summary.textContent = '';
1149
+ const $title = document.createElement('p');
1150
+ $title.setAttribute('data-form-father-summary-title', '');
1151
+ $title.textContent = options.title || 'Проверьте поля формы';
1152
+ const $list = document.createElement('ul');
1153
+ $list.setAttribute('data-form-father-summary-list', '');
1154
+ errors.forEach(error => {
1155
+ const $item = document.createElement('li');
1156
+ const $field = error.field === FORM_ERROR_FIELD ? null : this.findFieldByName(error.field);
1157
+ if ($field) {
1158
+ const $button = document.createElement('button');
1159
+ $button.type = 'button';
1160
+ $button.setAttribute('data-form-father-summary-field', error.field);
1161
+ $button.textContent = error.message;
1162
+ $button.addEventListener('click', () => this.focusFieldFromSummary($field));
1163
+ $item.appendChild($button);
1164
+ }
1165
+ else {
1166
+ const $message = document.createElement('span');
1167
+ $message.textContent = error.message;
1168
+ $item.appendChild($message);
1169
+ }
1170
+ $list.appendChild($item);
1171
+ });
1172
+ $summary.append($title, $list);
1173
+ }
1174
+ renderErrorSummary(errors = this.getErrors()) {
1175
+ var _a;
1176
+ const options = this.getErrorSummaryOptions();
1177
+ if (!options)
1178
+ return;
1179
+ if (errors.length === 0) {
1180
+ this.hideErrorSummary();
1181
+ return;
1182
+ }
1183
+ const $summary = this.getErrorSummaryContainer(options, true);
1184
+ if (!$summary)
1185
+ return;
1186
+ this.prepareErrorSummaryContainer($summary);
1187
+ const rendered = (_a = options.render) === null || _a === void 0 ? void 0 : _a.call(options, errors.map(error => (Object.assign({}, error))), this);
1188
+ if (rendered instanceof HTMLElement) {
1189
+ $summary.replaceChildren(rendered);
1190
+ }
1191
+ else if (typeof rendered === 'string') {
1192
+ $summary.innerHTML = rendered;
1193
+ }
1194
+ else if (!options.render) {
1195
+ this.renderDefaultErrorSummary($summary, errors, options);
1196
+ }
1197
+ if (options.focus)
1198
+ this.focusErrorSummary($summary);
1199
+ }
1200
+ hideErrorSummary() {
1201
+ const options = this.getErrorSummaryOptions() || {};
1202
+ const $summary = this.getErrorSummaryContainer(options, false);
1203
+ if (!$summary)
1204
+ return;
1205
+ $summary.replaceChildren();
1206
+ $summary.setAttribute('hidden', '');
1207
+ }
1208
+ syncErrorSummary() {
1209
+ if (this.errors.length === 0) {
1210
+ this.hideErrorSummary();
1211
+ return;
1212
+ }
1213
+ this.renderErrorSummary();
1214
+ }
1037
1215
  /**
1038
1216
  * Проскроливает до первого ошибочного поля.
1039
1217
  *
@@ -1331,6 +1509,7 @@ class Form {
1331
1509
  this.showError($field, message);
1332
1510
  $field.removeAttribute('aria-busy');
1333
1511
  this.setFieldValidationState($field, 'invalid');
1512
+ this.syncErrorSummary();
1334
1513
  return true;
1335
1514
  }
1336
1515
  /** Проверяет одно поле по имени или DOM-элементу. */
@@ -1342,7 +1521,9 @@ class Form {
1342
1521
  return false;
1343
1522
  }
1344
1523
  const { rules, messages } = this.getFieldValidationConfig($field);
1345
- return this.runFieldValidation($field, rules, messages);
1524
+ const isValid = yield this.runFieldValidation($field, rules, messages);
1525
+ this.syncErrorSummary();
1526
+ return isValid;
1346
1527
  });
1347
1528
  }
1348
1529
  /**
@@ -1394,6 +1575,12 @@ class Form {
1394
1575
  if (!ok && this.config.focusFirstErroredInput) {
1395
1576
  this.focusFirstErroredInput(visibleErroredInputs);
1396
1577
  }
1578
+ if (ok) {
1579
+ this.hideErrorSummary();
1580
+ }
1581
+ else {
1582
+ this.renderErrorSummary();
1583
+ }
1397
1584
  return ok;
1398
1585
  });
1399
1586
  }
@@ -1620,6 +1807,7 @@ class Form {
1620
1807
  this._cancelAnimations(formErrorWrapper);
1621
1808
  formErrorWrapper.remove();
1622
1809
  }
1810
+ this.hideErrorSummary();
1623
1811
  // 3) Скрыть/удалить лоадер на кнопке submit
1624
1812
  this.$submits.forEach($submit => {
1625
1813
  $submit.classList.remove('button--loading');
@@ -1749,6 +1937,7 @@ class Form {
1749
1937
  if (responseBody.error !== true)
1750
1938
  return;
1751
1939
  if (responseBody['error-msg']) {
1940
+ this.setErrorRecord(FORM_ERROR_FIELD, 'server', responseBody['error-msg'], 'server');
1752
1941
  this.showErrorForm(responseBody['error-msg']);
1753
1942
  }
1754
1943
  if (responseBody.errors) {
@@ -1763,6 +1952,7 @@ class Form {
1763
1952
  this.setFieldValidationState($field, 'invalid');
1764
1953
  }
1765
1954
  else {
1955
+ this.setErrorRecord(error.name, 'server', error['error-msg'], 'server');
1766
1956
  console.warn(`Не найдено поле с именем: ${error.name}, для вывода ошибки: ${error['error-msg']}`);
1767
1957
  }
1768
1958
  });
@@ -1771,6 +1961,7 @@ class Form {
1771
1961
  if (this.config.focusFirstErroredInput === true)
1772
1962
  this.focusFirstErroredInput(fieldsList);
1773
1963
  }
1964
+ this.syncErrorSummary();
1774
1965
  }
1775
1966
  sendData() {
1776
1967
  return __awaiter(this, void 0, void 0, function* () {
package/dist/index.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
1
+ {"version":3,"file":"index.js","sources":[],"sourcesContent":[],"names":[],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;"}
@@ -24,6 +24,16 @@ export interface SubmitResult {
24
24
  export interface FormResetOptions {
25
25
  clearErrors?: boolean;
26
26
  }
27
+ export interface ErrorSummaryOptions {
28
+ /** Existing summary container selector. If omitted, Form Father creates `[data-form-father-summary]` in the form. */
29
+ selector?: string;
30
+ /** Heading text for the default summary renderer. */
31
+ title?: string;
32
+ /** Move focus to the summary after rendering. Useful for submit-time screen reader announcements. */
33
+ focus?: boolean;
34
+ /** Custom renderer. Return an element/string or render into your own container and return nothing. */
35
+ render?: (errors: ValidationError[], formInstance: Form) => HTMLElement | string | void;
36
+ }
27
37
  export interface ValidationIssue {
28
38
  field?: string;
29
39
  rule?: string;
@@ -146,6 +156,12 @@ export interface FormOptions {
146
156
  errorContainerAttribute?: string;
147
157
  /** Атрибут, куда пишется состояние поля: `validating`, `valid` или `invalid`. */
148
158
  validationStateAttribute?: string;
159
+ /** Связывать текст ошибки с полем через `aria-describedby`. По умолчанию `true`. */
160
+ ariaDescribeErrors?: boolean;
161
+ /** Префикс id для автоматически созданных элементов ошибок. */
162
+ errorIdPrefix?: string;
163
+ /** Error summary для всей формы. По умолчанию выключен; `true` включает дефолтный renderer. */
164
+ errorSummary?: boolean | ErrorSummaryOptions;
149
165
  /** Следить за динамически добавленными/удалёнными полями и submit-кнопками. По умолчанию `false`. */
150
166
  observeMutations?: boolean;
151
167
  /** Функция для обёртки отправляемых данных. */
@@ -243,6 +259,11 @@ export default class Form {
243
259
  private scheduleFieldValidation;
244
260
  private setupMutationObserver;
245
261
  private getErrorContainer;
262
+ private toSafeIdPart;
263
+ private createErrorId;
264
+ private ensureElementId;
265
+ private addErrorDescription;
266
+ private removeErrorDescription;
246
267
  /**
247
268
  * Показывает ошибку для поля ввода.
248
269
  *
@@ -260,6 +281,15 @@ export default class Form {
260
281
  * @param {HTMLInputElement} $input - Поле ввода.
261
282
  */
262
283
  private hideError;
284
+ private getErrorSummaryOptions;
285
+ private getErrorSummaryContainer;
286
+ private prepareErrorSummaryContainer;
287
+ private focusErrorSummary;
288
+ private focusFieldFromSummary;
289
+ private renderDefaultErrorSummary;
290
+ private renderErrorSummary;
291
+ private hideErrorSummary;
292
+ private syncErrorSummary;
263
293
  /**
264
294
  * Проскроливает до первого ошибочного поля.
265
295
  *
@@ -0,0 +1,153 @@
1
+ # Form Father API Reference
2
+
3
+ This page is a compact reference for the public API exported by `form-father`.
4
+
5
+ ## Main Import
6
+
7
+ ```ts
8
+ import Form from 'form-father';
9
+ ```
10
+
11
+ `Form` is the default export and is constructed with a native `<form>` element.
12
+
13
+ ```ts
14
+ const form = new Form(document.querySelector('form')!, {
15
+ inputWrapperSelector: '.field',
16
+ validateOn: ['blur', 'change'],
17
+ revalidateOn: ['input', 'change'],
18
+ errorSummary: true,
19
+ });
20
+ ```
21
+
22
+ ## Form Options
23
+
24
+ | Option | Type | Default | Notes |
25
+ | --- | --- | --- | --- |
26
+ | `inputSelector` | `string` | `.input` | Selector used by legacy input collection helpers. |
27
+ | `inputWrapperSelector` | `string` | `.input-primary` | Field wrapper selector used for inline error rendering. |
28
+ | `showLoaderButton` | `boolean` | `true` | Adds a loader to submit buttons while a request is pending. |
29
+ | `loaderColor` | `string` | `#fff` | Loader SVG color. |
30
+ | `scrollToFirstErroredInput` | `boolean` | `true` | Scrolls to the first invalid field. |
31
+ | `focusFirstErroredInput` | `boolean` | `false` | Focuses the first invalid field after validation. |
32
+ | `validateOn` | `ValidationTrigger | ValidationTrigger[]` | `submit` | Live validation triggers: `submit`, `input`, `blur`, `change`. |
33
+ | `revalidateOn` | `ValidationTrigger | ValidationTrigger[]` | `input`, `change` | Rechecks fields that are already invalid. |
34
+ | `validationDebounce` | `number` | `0` | Delay for live validation. |
35
+ | `validationSchema` | `ValidationSchema` | built in defaults | Field rules matched by selectors or `data-validate`. |
36
+ | `formValidators` | `FormValidator | FormValidator[]` | none | Cross-field and form-level validation. |
37
+ | `errorContainerAttribute` | `string` | `data-error-container` | Attribute containing a selector for a custom error container. |
38
+ | `validationStateAttribute` | `string` | `data-form-father-state` | Field state attribute: `validating`, `valid`, `invalid`. |
39
+ | `ariaDescribeErrors` | `boolean` | `true` | Links inline errors to fields with `aria-describedby`. |
40
+ | `errorIdPrefix` | `string` | `form-father-error` | Prefix for generated error element ids. |
41
+ | `errorSummary` | `boolean | ErrorSummaryOptions` | `false` | Enables an accessible form error summary. |
42
+ | `observeMutations` | `boolean` | `false` | Rebinds controls when fields/buttons are added dynamically. |
43
+ | `wrapData` | `(data) => data` | none | Transforms form values before request body creation. |
44
+ | `logging` | `boolean` | `false` | Logs submitted `FormData` values. |
45
+
46
+ ## Lifecycle Callbacks
47
+
48
+ | Callback | When it runs |
49
+ | --- | --- |
50
+ | `onBeforeValidate(form)` | Before submit-time validation. |
51
+ | `onAfterValidate(isValid, form)` | After validation and before submit. |
52
+ | `onValidationError(errors, form)` | When client-side validation fails. |
53
+ | `onBeforeSubmit(form)` | Before a valid form is sent. Return `false` to cancel. |
54
+ | `onSubmit(form)` | When request sending starts. |
55
+ | `onResponse(body, form)` | After a response body is parsed. |
56
+ | `onResponseSuccess(body, form)` | For successful HTTP responses with `success: true`. |
57
+ | `onResponseUnsuccess(body, form)` | For non-OK responses, `success !== true`, or invalid JSON. |
58
+ | `onSubmitError(error, form)` | When sending or response parsing throws. |
59
+
60
+ ## Instance Methods
61
+
62
+ | Method | Returns | Purpose |
63
+ | --- | --- | --- |
64
+ | `validate()` | `Promise<boolean>` | Validates the whole form. |
65
+ | `validateField(field)` | `Promise<boolean>` | Validates one field by `name` or element. |
66
+ | `submit()` | `Promise<SubmitResult | undefined>` | Sends the form programmatically. |
67
+ | `showFieldError(field, message, source?)` | `boolean` | Shows a field error manually. |
68
+ | `setErrors(errors, source?)` | `this` | Applies backend, form-level, or manual errors. |
69
+ | `getErrors()` | `ValidationError[]` | Returns current error records. |
70
+ | `getValues()` | `Record<string, any>` | Serializes the form into a plain object. |
71
+ | `setValues(values)` | `this` | Sets field values by `name` and dispatches `input`/`change`. |
72
+ | `clearErrors()` | `this` | Clears field and form errors. |
73
+ | `reset(options?)` | `this` | Calls native `form.reset()` and clears errors by default. |
74
+ | `clearInputs()` | `void` | Clears input values without resetting native defaults. |
75
+ | `updateOptions(options)` | `this` | Updates options and rebinds listeners. |
76
+ | `destroy()` | `void` | Removes listeners, loaders, error nodes, and runtime state. |
77
+
78
+ ## Static Methods and Constants
79
+
80
+ ```ts
81
+ Form.initAll('form[data-form-father]', options);
82
+ Form.setDefaultParams({ inputWrapperSelector: '.field' });
83
+ Form.defaultValidationSchema = {};
84
+ ```
85
+
86
+ | Export | Purpose |
87
+ | --- | --- |
88
+ | `FORM_ERROR_FIELD` | Constant for form-level errors in `getErrors()` and `setErrors()`. |
89
+ | `Form.initAll(selector, options)` | Initializes matching forms and reuses existing instances. |
90
+ | `Form.setDefaultParams(params)` | Merges global default options for future instances. |
91
+ | `Form.defaultValidationSchema` | Global default validation schema. |
92
+
93
+ ## Validators
94
+
95
+ ```ts
96
+ import {
97
+ createLengthValidator,
98
+ createPatternValidator,
99
+ registerFieldValidator,
100
+ registerValidator,
101
+ } from 'form-father';
102
+ ```
103
+
104
+ | Export | Purpose |
105
+ | --- | --- |
106
+ | `registerValidator(name, fn, message, options?)` | Registers a full validator. |
107
+ | `registerFieldValidator(name, predicate, message, options?)` | Registers a predicate-only field validator. |
108
+ | `getValidator(name)` | Reads one validator definition. |
109
+ | `getAllValidators()` | Reads the validator registry. |
110
+ | `createPatternValidator(pattern)` | Builds a regex validator. |
111
+ | `createLengthValidator(options)` | Builds min/max length validation. |
112
+
113
+ ## Form Validators
114
+
115
+ ```ts
116
+ import { dateOrder, requiredIf, sameAsField } from 'form-father';
117
+ ```
118
+
119
+ | Export | Purpose |
120
+ | --- | --- |
121
+ | `createFormValidator(predicate, issue)` | Builds a reusable cross-field validator. |
122
+ | `sameAsField(field, otherField, message?, rule?)` | Checks that two values match. |
123
+ | `requiredIf(field, condition, message?, rule?)` | Makes a field required when a predicate is true. |
124
+ | `dateOrder(startField, endField, message?, rule?)` | Checks chronological field order. |
125
+
126
+ ## Schema Adapters
127
+
128
+ ```ts
129
+ import { createSchemaValidator, registerSchemaValidator } from 'form-father';
130
+ ```
131
+
132
+ Adapters expect a schema-like object with either `safeParse(value)` or `parse(value)`.
133
+
134
+ | Export | Purpose |
135
+ | --- | --- |
136
+ | `createSchemaValidator(schema, message?)` | Converts a schema into a field validator function. |
137
+ | `registerSchemaValidator(name, schema, message?, options?)` | Registers a schema-backed rule. |
138
+ | `createFieldValidator(predicate, message)` | Creates a simple predicate validator. |
139
+
140
+ ## Helpers
141
+
142
+ | Export | Purpose |
143
+ | --- | --- |
144
+ | `serializeToFormData(element)` | Serializes a form-like element into `FormData`. |
145
+ | `serializeFormToJSON(form)` | Serializes a form into a plain object. |
146
+ | `isEmailValid(value)` | Checks email format. |
147
+ | `isUrlValid(value)` | Checks URLs, domains, IPs, and `localhost`. |
148
+ | `isPhoneValid(value)` | Checks Russian `+7XXXXXXXXXX` phone numbers. |
149
+ | `closest(element, selector)` | Finds a closest matching ancestor. |
150
+ | `parseCommonResponseProperties(body)` | Normalizes common response flags. |
151
+ | `blockScrollBody()` | Locks body scrolling. |
152
+ | `unblockScrollBody()` | Restores body scrolling. |
153
+
@@ -0,0 +1,37 @@
1
+ # Demo Guide
2
+
3
+ The demo is a static, dependency-light page that exercises common Form Father flows after a package build.
4
+
5
+ ```bash
6
+ npm run build
7
+ npm run demos
8
+ ```
9
+
10
+ Open the Vite URL printed by the command. The page loads `dist/FormFather.min.js` and `demos/main.js`, so it reflects the
11
+ actual browser bundle.
12
+
13
+ ## Demo Forms
14
+
15
+ | Form | What it demonstrates |
16
+ | --- | --- |
17
+ | Login | Live validation, async validator state, password confirmation, and custom error containers. |
18
+ | Callback | Server-side field errors and form-level API errors. |
19
+ | Search | `GET` forms and query-string body handling. |
20
+ | Multipart | Browser-managed `FormData` upload flow. |
21
+ | Public API | `setValues()`, `validateField()`, `setErrors()`, `getValues()`, and `clearErrors()`. |
22
+
23
+ ## Useful Manual Checks
24
+
25
+ - Enter `taken@example.com` in the Login email field to see async validation fail.
26
+ - Submit Callback with a valid phone to see backend field errors applied after the mocked response.
27
+ - Clear the Search query and submit to inspect the accessible error summary.
28
+ - Use Public API buttons to inspect programmatic state changes in the Output panel.
29
+
30
+ ## Files
31
+
32
+ | File | Purpose |
33
+ | --- | --- |
34
+ | `demos/index.html` | Demo markup and forms. |
35
+ | `demos/main.js` | Mock API, validator registration, `Form.initAll()`, and public API playground actions. |
36
+ | `demos/styles.css` | Minimal UI styling for fields, summaries, output, and loading states. |
37
+
package/docs/en/README.md CHANGED
@@ -60,6 +60,7 @@ import Form, {
60
60
  type FormValidator,
61
61
  type ValidationIssue,
62
62
  type FormValidatorPredicate,
63
+ type ErrorSummaryOptions,
63
64
  } from 'form-father';
64
65
  ```
65
66
 
@@ -85,6 +86,9 @@ import Form, {
85
86
  `data-error-container`.
86
87
  - **validationStateAttribute**: Field state attribute: `validating`, `valid`, or `invalid`. Defaults to
87
88
  `data-form-father-state`.
89
+ - **ariaDescribeErrors**: Links inline error text to the field with `aria-describedby`. Defaults to `true`.
90
+ - **errorIdPrefix**: Prefix for automatically generated error element ids. Defaults to `form-father-error`.
91
+ - **errorSummary**: Form error summary: `true` for the default renderer or `{ selector, title, focus, render }`.
88
92
  - **observeMutations**: Watches dynamically added fields and submit buttons.
89
93
  - **formValidators**: A form validator or array of validators for cross-field rules.
90
94
 
@@ -135,8 +139,38 @@ import Form, {
135
139
  - `data-error-container` points to a custom error container without requiring wrapper markup.
136
140
  - During async validation, fields receive `aria-busy="true"` and a `validating` state attribute; stale validator
137
141
  responses are ignored.
142
+ - Inline errors receive `role="alert"` and are linked to fields with `aria-describedby`.
138
143
  - `data-custom-validate` is still supported for backward compatibility.
139
144
 
145
+ ### Error summary and a11y
146
+
147
+ ```html
148
+ <form data-form-father novalidate>
149
+ <div data-form-father-summary hidden></div>
150
+ <label>
151
+ Email
152
+ <input class="input" name="email" data-validate="required|email" />
153
+ </label>
154
+ <button type="submit">Submit</button>
155
+ </form>
156
+ ```
157
+
158
+ ```ts
159
+ new Form($form, {
160
+ inputWrapperSelector: 'label',
161
+ errorSummary: {
162
+ title: 'Please check the form',
163
+ focus: true,
164
+ },
165
+ });
166
+ ```
167
+
168
+ - If the form already contains `[data-form-father-summary]`, Form Father uses it; otherwise `errorSummary: true` creates
169
+ a summary at the start of the form.
170
+ - `selector` sends the summary into your own container.
171
+ - `render(errors, form)` lets you fully replace the summary markup.
172
+ - `ariaDescribeErrors: false` disables automatic error id insertion into `aria-describedby`.
173
+
140
174
  Final rule order:
141
175
 
142
176
  ```text
@@ -192,9 +226,12 @@ For simple checks, use `createFieldValidator(predicate, message)`.
192
226
 
193
227
  ## Demos and Recipes
194
228
 
195
- - `demos/index.html` is a static login/callback/search/upload demo after `npm run build`.
229
+ - `demos/index.html` is a static login/callback/search/upload/public API demo after `npm run build`.
196
230
  - `npm run demos` builds the package and starts the Vite demo server.
231
+ - `docs/api/README.md` is a compact reference for public API, options, methods, and exports.
232
+ - `docs/demo/README.md` explains manual demo checks and demo form coverage.
197
233
  - `docs/recipes/README.md` contains short API, data-attribute, adapter, and server-error recipes.
234
+ - `CHANGELOG.md` tracks releases and coverage baselines.
198
235
 
199
236
  ## Helpers
200
237
 
@@ -216,6 +253,7 @@ The library provides a number of utility functions:
216
253
 
217
254
  - **npm run build**: Builds the project.
218
255
  - **npm run watch**: Builds the project in watch mode.
256
+ - **npm run docs:check**: Checks docs links, version references, demo files, and package README sync.
219
257
  - **npm run demos**: Runs a demo version using Vite.
220
258
 
221
259
  ## Contributing
@@ -122,3 +122,29 @@ adding runtime dependencies to Form Father.
122
122
  ```
123
123
 
124
124
  Field errors are shown through the same rendering path as client-side validation errors.
125
+
126
+ ## Accessible error summary
127
+
128
+ ```html
129
+ <form data-form-father novalidate>
130
+ <div data-form-father-summary hidden></div>
131
+ <label>
132
+ Email
133
+ <input class="input" name="email" data-validate="required|email" />
134
+ </label>
135
+ <button type="submit">Submit</button>
136
+ </form>
137
+ ```
138
+
139
+ ```ts
140
+ const form = new Form(document.querySelector('form')!, {
141
+ inputWrapperSelector: 'label',
142
+ errorSummary: {
143
+ title: 'Please check the form',
144
+ focus: true,
145
+ },
146
+ });
147
+ ```
148
+
149
+ Inline errors are linked to fields with `aria-describedby` by default. Use `ariaDescribeErrors: false` if your design
150
+ system owns that relationship itself.