@aurodesignsystem-dev/auro-formkit 0.0.0-pr1503.3 → 0.0.0-pr1503.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (190) hide show
  1. package/components/bibtemplate/dist/index.js +1 -1
  2. package/components/bibtemplate/dist/registered.js +1 -1
  3. package/components/checkbox/demo/accessibility.md +1 -1
  4. package/components/checkbox/demo/customize.html +1 -2
  5. package/components/checkbox/demo/customize.min.js +23 -8
  6. package/components/checkbox/demo/getting-started.min.js +23 -8
  7. package/components/checkbox/demo/index.min.js +23 -8
  8. package/components/checkbox/dist/index.js +23 -8
  9. package/components/checkbox/dist/registered.js +23 -8
  10. package/components/combobox/README.md +1 -1
  11. package/components/combobox/demo/accessibility.md +1 -1
  12. package/components/combobox/demo/api.md +2 -2
  13. package/components/combobox/demo/customize.html +1 -2
  14. package/components/combobox/demo/customize.md +166 -142
  15. package/components/combobox/demo/customize.min.js +2515 -2316
  16. package/components/combobox/demo/getting-started.min.js +2515 -2312
  17. package/components/combobox/demo/index.md +2 -1
  18. package/components/combobox/demo/index.min.js +2515 -2312
  19. package/components/combobox/demo/keyboard-behavior.md +3 -146
  20. package/components/combobox/demo/readme.md +1 -1
  21. package/components/combobox/demo/why-combobox.md +2 -2
  22. package/components/combobox/dist/auro-combobox.d.ts +49 -15
  23. package/components/combobox/dist/index.js +1462 -753
  24. package/components/combobox/dist/registered.js +1462 -753
  25. package/components/counter/demo/customize.min.js +188 -244
  26. package/components/counter/demo/index.min.js +188 -244
  27. package/components/counter/dist/auro-counter.d.ts +0 -8
  28. package/components/counter/dist/index.js +188 -244
  29. package/components/counter/dist/registered.js +188 -244
  30. package/components/datepicker/demo/accessibility.md +20 -10
  31. package/components/datepicker/demo/api.md +65 -62
  32. package/components/datepicker/demo/customize.md +180 -40
  33. package/components/datepicker/demo/customize.min.js +1690 -777
  34. package/components/datepicker/demo/getting-started.md +118 -2
  35. package/components/datepicker/demo/index.min.js +1690 -759
  36. package/components/datepicker/demo/keyboard-behavior.md +3 -3
  37. package/components/datepicker/demo/voiceover.md +4 -4
  38. package/components/datepicker/demo/why-datepicker.md +2 -2
  39. package/components/datepicker/dist/{src/auro-calendar-cell.d.ts → auro-calendar-cell.d.ts} +48 -3
  40. package/components/datepicker/dist/{src/auro-calendar.d.ts → auro-calendar.d.ts} +188 -10
  41. package/components/datepicker/dist/{src/auro-datepicker.d.ts → auro-datepicker.d.ts} +89 -6
  42. package/components/datepicker/dist/blackoutUtils.d.ts +43 -0
  43. package/components/datepicker/dist/index.js +1690 -759
  44. package/components/datepicker/dist/registered.js +1690 -759
  45. package/components/dropdown/demo/customize.html +3 -0
  46. package/components/dropdown/demo/customize.min.js +142 -208
  47. package/components/dropdown/demo/getting-started.min.js +131 -207
  48. package/components/dropdown/demo/index.min.js +131 -207
  49. package/components/dropdown/dist/auro-dropdown.d.ts +17 -1
  50. package/components/dropdown/dist/index.js +131 -207
  51. package/components/dropdown/dist/registered.js +131 -207
  52. package/components/form/demo/api.md +3 -3
  53. package/components/form/demo/customize.html +6 -6
  54. package/components/form/demo/customize.md +535 -135
  55. package/components/form/demo/customize.min.js +6307 -4378
  56. package/components/form/demo/getting-started.md +344 -96
  57. package/components/form/demo/getting-started.min.js +6255 -4378
  58. package/components/form/demo/index.min.js +6206 -4378
  59. package/components/form/demo/registerDemoDeps.min.js +5827 -4333
  60. package/components/form/dist/auro-form.d.ts +128 -10
  61. package/components/form/dist/index.js +377 -43
  62. package/components/form/dist/registered.js +377 -43
  63. package/components/input/demo/accessibility.md +1 -1
  64. package/components/input/demo/api.md +58 -57
  65. package/components/input/demo/customize.html +1 -2
  66. package/components/input/demo/customize.md +62 -61
  67. package/components/input/demo/customize.min.js +396 -275
  68. package/components/input/demo/getting-started.min.js +396 -275
  69. package/components/input/demo/index.min.js +396 -275
  70. package/components/input/dist/auro-input.d.ts +1 -1
  71. package/components/input/dist/base-input.d.ts +60 -81
  72. package/components/input/dist/index.js +409 -276
  73. package/components/input/dist/registered.js +396 -275
  74. package/components/input/dist/utilities.d.ts +10 -1
  75. package/components/menu/demo/accessibility.md +9 -4
  76. package/components/menu/demo/api.md +48 -45
  77. package/components/menu/demo/css-only.md +26 -19
  78. package/components/menu/demo/customize.md +307 -75
  79. package/components/menu/demo/design.md +1 -1
  80. package/components/menu/demo/getting-started.md +144 -7
  81. package/components/menu/demo/index.min.js +961 -1341
  82. package/components/menu/demo/keyboard-behavior.md +83 -4
  83. package/components/menu/demo/voiceover.md +21 -14
  84. package/components/menu/demo/why-menu.md +8 -9
  85. package/components/menu/dist/auro-menu-utils.d.ts +19 -0
  86. package/components/menu/dist/auro-menu.d.ts +97 -116
  87. package/components/menu/dist/auro-menuoption.d.ts +39 -133
  88. package/components/menu/dist/index.js +823 -1309
  89. package/components/menu/dist/registered.js +835 -1309
  90. package/components/radio/demo/accessibility.md +1 -1
  91. package/components/radio/demo/customize.min.js +23 -8
  92. package/components/radio/demo/getting-started.min.js +23 -8
  93. package/components/radio/demo/index.min.js +23 -8
  94. package/components/radio/dist/index.js +23 -8
  95. package/components/radio/dist/registered.js +23 -8
  96. package/components/select/demo/accessibility.md +6 -1
  97. package/components/select/demo/api.md +3 -2
  98. package/components/select/demo/customize.html +1 -2
  99. package/components/select/demo/customize.md +210 -5
  100. package/components/select/demo/customize.min.js +1613 -1854
  101. package/components/select/demo/design.md +10 -10
  102. package/components/select/demo/getting-started.md +1 -1
  103. package/components/select/demo/getting-started.min.js +1617 -1903
  104. package/components/select/demo/index.md +2 -2
  105. package/components/select/demo/index.min.js +1613 -1854
  106. package/components/select/demo/keyboard-behavior.md +81 -54
  107. package/components/select/demo/voiceover.md +28 -15
  108. package/components/select/dist/auro-select.d.ts +70 -12
  109. package/components/select/dist/index.js +580 -315
  110. package/components/select/dist/registered.js +580 -315
  111. package/components/select/dist/selectUtils.d.ts +24 -0
  112. package/custom-elements.json +8071 -7919
  113. package/package.json +7 -3
  114. package/components/checkbox/demo/customize.js +0 -22
  115. package/components/checkbox/demo/demo-support.js +0 -1
  116. package/components/checkbox/demo/getting-started.js +0 -22
  117. package/components/checkbox/demo/index.js +0 -8
  118. package/components/checkbox/demo/styles.scss +0 -1
  119. package/components/combobox/demo/customize.js +0 -24
  120. package/components/combobox/demo/demo-support.js +0 -1
  121. package/components/combobox/demo/getting-started.js +0 -21
  122. package/components/combobox/demo/index.js +0 -23
  123. package/components/combobox/demo/styles.scss +0 -1
  124. package/components/counter/demo/customize.js +0 -21
  125. package/components/counter/demo/demo-support.js +0 -1
  126. package/components/counter/demo/index.js +0 -21
  127. package/components/counter/demo/styles.scss +0 -1
  128. package/components/datepicker/demo/customize.js +0 -19
  129. package/components/datepicker/demo/demo-support.js +0 -1
  130. package/components/datepicker/demo/index.js +0 -23
  131. package/components/datepicker/demo/styles.scss +0 -1
  132. package/components/dropdown/demo/customize.js +0 -9
  133. package/components/dropdown/demo/demo-support.js +0 -1
  134. package/components/dropdown/demo/getting-started.js +0 -9
  135. package/components/dropdown/demo/index.js +0 -16
  136. package/components/dropdown/demo/styles.scss +0 -1
  137. package/components/form/demo/customize.js +0 -9
  138. package/components/form/demo/demo-support.js +0 -1
  139. package/components/form/demo/getting-started.js +0 -9
  140. package/components/form/demo/index.js +0 -5
  141. package/components/form/demo/registerDemoDeps.js +0 -23
  142. package/components/form/demo/styles.scss +0 -1
  143. package/components/input/demo/customize.js +0 -25
  144. package/components/input/demo/demo-support.js +0 -1
  145. package/components/input/demo/getting-started.js +0 -8
  146. package/components/input/demo/index.js +0 -8
  147. package/components/input/demo/styles.css +0 -974
  148. package/components/input/demo/styles.scss +0 -1
  149. package/components/menu/demo/demo-support.js +0 -1
  150. package/components/menu/demo/index.js +0 -25
  151. package/components/menu/demo/styles.scss +0 -1
  152. package/components/menu/dist/auro-menu.context.d.ts +0 -238
  153. package/components/radio/demo/customize.js +0 -22
  154. package/components/radio/demo/demo-support.js +0 -1
  155. package/components/radio/demo/getting-started.js +0 -24
  156. package/components/radio/demo/index.js +0 -8
  157. package/components/radio/demo/styles.scss +0 -1
  158. package/components/select/demo/customize.js +0 -11
  159. package/components/select/demo/demo-support.js +0 -1
  160. package/components/select/demo/getting-started.js +0 -26
  161. package/components/select/demo/index.js +0 -11
  162. package/components/select/demo/styles.scss +0 -1
  163. /package/components/counter/dist/styles/{counter-wrapper-color-css.d.ts → counter-group-color-css.d.ts} +0 -0
  164. /package/components/datepicker/dist/{src/auro-calendar-month.d.ts → auro-calendar-month.d.ts} +0 -0
  165. /package/components/datepicker/dist/{src/buttonVersion.d.ts → buttonVersion.d.ts} +0 -0
  166. /package/components/datepicker/dist/{src/datepickerKeyboardStrategy.d.ts → datepickerKeyboardStrategy.d.ts} +0 -0
  167. /package/components/datepicker/dist/{src/iconVersion.d.ts → iconVersion.d.ts} +0 -0
  168. /package/components/datepicker/dist/{src/index.d.ts → index.d.ts} +0 -0
  169. /package/components/datepicker/dist/{src/popoverVersion.d.ts → popoverVersion.d.ts} +0 -0
  170. /package/components/datepicker/dist/{src/styles → styles}/classic/color-css.d.ts +0 -0
  171. /package/components/datepicker/dist/{src/styles → styles}/classic/style-css.d.ts +0 -0
  172. /package/components/datepicker/dist/{src/styles → styles}/color-calendar-css.d.ts +0 -0
  173. /package/components/datepicker/dist/{src/styles → styles}/color-cell-css.d.ts +0 -0
  174. /package/components/datepicker/dist/{src/styles → styles}/color-css.d.ts +0 -0
  175. /package/components/datepicker/dist/{src/styles → styles}/color-month-css.d.ts +0 -0
  176. /package/components/datepicker/dist/{src/styles → styles}/shapeSize-css.d.ts +0 -0
  177. /package/components/datepicker/dist/{src/styles → styles}/snowflake/color-css.d.ts +0 -0
  178. /package/components/datepicker/dist/{src/styles → styles}/snowflake/style-css.d.ts +0 -0
  179. /package/components/datepicker/dist/{src/styles → styles}/style-auro-calendar-cell-css.d.ts +0 -0
  180. /package/components/datepicker/dist/{src/styles → styles}/style-auro-calendar-css.d.ts +0 -0
  181. /package/components/datepicker/dist/{src/styles → styles}/style-auro-calendar-month-css.d.ts +0 -0
  182. /package/components/datepicker/dist/{src/styles → styles}/style-css.d.ts +0 -0
  183. /package/components/datepicker/dist/{src/styles → styles}/tokens-css.d.ts +0 -0
  184. /package/components/datepicker/dist/{src/utilities.d.ts → utilities.d.ts} +0 -0
  185. /package/components/datepicker/dist/{src/utilitiesCalendar.d.ts → utilitiesCalendar.d.ts} +0 -0
  186. /package/components/datepicker/dist/{src/utilitiesCalendarRender.d.ts → utilitiesCalendarRender.d.ts} +0 -0
  187. /package/components/datepicker/dist/{src/vendor → vendor}/wc-range-datepicker/day.d.ts +0 -0
  188. /package/components/datepicker/dist/{src/vendor → vendor}/wc-range-datepicker/range-datepicker-calendar.d.ts +0 -0
  189. /package/components/datepicker/dist/{src/vendor → vendor}/wc-range-datepicker/range-datepicker-cell.d.ts +0 -0
  190. /package/components/datepicker/dist/{src/vendor → vendor}/wc-range-datepicker/range-datepicker.d.ts +0 -0
@@ -85,15 +85,15 @@ class AuroLibraryRuntimeUtils {
85
85
  }
86
86
  }
87
87
 
88
- /* eslint-disable no-underscore-dangle, max-lines, object-property-newline */
88
+ /* eslint-disable no-underscore-dangle, max-lines, object-property-newline, dot-location */
89
89
 
90
90
 
91
91
  /**
92
92
  * @typedef {Object} FormStateMember - The form state member.
93
- * @property {string | number | boolean | string[] | null} value - The value of the form element.
93
+ * @property {string | number | boolean | string[] | Record<string, number> | null} value - The value of the form element. Mirrors the child component's own `.value`, so the shape varies by element type (e.g. an array for `auro-checkbox-group`, an object keyed by counter name for `auro-counter-group`). A `range` `auro-datepicker` is the one form-specific case: its `.values` array is stored rather than its single `.value` string.
94
94
  * @property {ValidityState} validity - The validity state of the form element, stored when fired from the form element.
95
95
  * @property {boolean} required - Whether the form element is required or not.
96
- * @property {HTMLElement} element - Whether the form element is required or not.
96
+ * @property {boolean} disabled - Whether the form element is currently disabled. Cached from the live attribute via the MutationObserver in `connectedCallback` and refreshed from `_handleAttributeMutations`.
97
97
  */
98
98
 
99
99
  /**
@@ -177,6 +177,31 @@ class AuroForm extends LitElement {
177
177
  */
178
178
  this.mutationObservers = [];
179
179
 
180
+ /**
181
+ * Captured initial (default) value per field `name`. Populated on first
182
+ * sight of each name in `_addElementToState` and preserved across
183
+ * subsequent `initializeState` cycles (slot change, rename, reset) so
184
+ * `_setInitialState` can detect user edits as `current !== initial`,
185
+ * matching HTML's `dirtyValueFlag` semantics.
186
+ * @private
187
+ * @type {Record<string, string | number | boolean | string[] | Record<string, number> | null | undefined>}
188
+ */
189
+ this._initialValues = {};
190
+
191
+ // Single subtree observer that watches `disabled` and `name` attribute
192
+ // changes across all tracked form elements. The `name` watch is required:
193
+ // without it, renaming a tracked field at runtime leaves a stale key in
194
+ // `formState` that `_isNameDisabled` cannot resolve, so a renamed-but-
195
+ // disabled field would re-appear in `.value`.
196
+ /**
197
+ * @private
198
+ * @type {MutationObserver | null}
199
+ */
200
+ this._attributeObserver = null;
201
+
202
+ /** @private */
203
+ this._handleAttributeMutations = this._handleAttributeMutations.bind(this);
204
+
180
205
  // Bind listeners
181
206
  /** @private */
182
207
  this.reset = this.reset.bind(this);
@@ -245,6 +270,45 @@ class AuroForm extends LitElement {
245
270
  return this._isInElementCollection(AuroForm.formElementTags, element);
246
271
  }
247
272
 
273
+ /**
274
+ * Whether a given element is currently disabled. Disabled controls are excluded
275
+ * from submission, validity, and initial-state checks per the HTML spec
276
+ * (section 4.10.19.2 "Enabling and disabling form controls":
277
+ * https://www.w3.org/TR/2011/WD-html5-20110113/association-of-controls-and-forms.html).
278
+ *
279
+ * Implementation note: we deliberately read only the attribute. Every Auro
280
+ * form element in `formElementTags` declares `disabled` with `reflect: true`,
281
+ * so the attribute and property stay in sync. Reading the attribute also
282
+ * lets the MutationObserver in `connectedCallback` (which is filtered to
283
+ * `['disabled', 'name']`) be the single source of truth for re-renders.
284
+ * If a future form-element type ships without attribute reflection, expand
285
+ * this helper to also read `element.disabled`.
286
+ * @param {HTMLElement | undefined | null} element - The element to check.
287
+ * @returns {boolean}
288
+ * @private
289
+ */
290
+ _isDisabled(element) {
291
+ return Boolean(element?.hasAttribute('disabled'));
292
+ }
293
+
294
+ /**
295
+ * Whether the tracked form element registered under `name` is currently disabled.
296
+ * See `_isDisabled` for the HTML-spec rationale behind excluding disabled
297
+ * controls from form state.
298
+ *
299
+ * Reads a cached flag on `formState[name]` populated by `_addElementToState`
300
+ * at registration and refreshed by `_handleAttributeMutations` whenever the
301
+ * element's `disabled` attribute toggles. The cache is fed by the same
302
+ * `hasAttribute('disabled')` read as `_isDisabled`, so the "future form-element
303
+ * type without attribute reflection" caveat documented there applies here too.
304
+ * @param {string} name - The `name` attribute used to register the element.
305
+ * @returns {boolean}
306
+ * @private
307
+ */
308
+ _isNameDisabled(name) {
309
+ return Boolean(this.formState[name]?.disabled);
310
+ }
311
+
248
312
  /**
249
313
  * Validates if an event is from a valid form element with a name.
250
314
  * @param {Event} event - The event to validate.
@@ -279,11 +343,15 @@ class AuroForm extends LitElement {
279
343
  }
280
344
 
281
345
  /**
282
- * Returns the current values of all named form elements as a key-value object, keyed by each element's `name` attribute.
283
- * @returns {Record<string, string | number | boolean | string[] | null>} The current form values.
346
+ * Returns the current values of all named, enabled form elements as a key-value object, keyed by each element's `name` attribute. Each value is the child component's own `.value`, so the shape depends on the element type — see that component's documentation for its exact shape (for example, `auro-checkbox-group` yields an array, `auro-counter-group` yields an object keyed by counter name, and `auro-select` with `multiSelect` yields a JSON-encoded string). The one form-specific exception is a `range` `auro-datepicker`, whose `.values` array (`[start, end]`) is stored instead of its single `.value` string.
347
+ * @returns {Record<string, string | number | boolean | string[] | Record<string, number> | null>} The current form values.
284
348
  */
285
349
  get value() {
286
350
  return Object.keys(this.formState).reduce((acc, key) => {
351
+ if (this._isNameDisabled(key)) {
352
+ return acc;
353
+ }
354
+
287
355
  acc[key] = this.formState[key].value;
288
356
  return acc;
289
357
  }, {});
@@ -307,23 +375,113 @@ class AuroForm extends LitElement {
307
375
  return this._resetElements;
308
376
  }
309
377
 
378
+ /**
379
+ * Raw constraint-validation check. Returns `true` when no enabled field
380
+ * has a validity error. Unlike the public `validity` getter, this does
381
+ * NOT gate on `isInitialState` — callers that need to make a decision
382
+ * based on the actual constraint state (submit-button enablement, the
383
+ * internal `submit()` gate) read this so a pre-filled valid form is
384
+ * correctly recognized as submittable at first render.
385
+ * @returns {boolean}
386
+ * @private
387
+ */
388
+ _isFormValid() {
389
+ return !Object.keys(this.formState).some((key) => {
390
+ if (this._isNameDisabled(key)) {
391
+ return false;
392
+ }
393
+
394
+ const formKey = this.formState[key];
395
+ // `null` validity means "not yet validated" — auro-input doesn't
396
+ // re-validate on every keystroke, so validity stays `null` between
397
+ // input events until blur. Treating `null` as invalid disables Submit
398
+ // the moment a user types into any field, which is the wrong UX.
399
+ // We treat a field as invalid in two cases:
400
+ // 1. validity is known-bad (non-null, non-'valid')
401
+ // 2. it's `required` and structurally empty — `valueMissing` is
402
+ // certain even without a validation pass.
403
+ // `submit()` calls `validate(true)` on every enabled field before
404
+ // reading `_isFormValid()`, so any not-yet-validated field that turns
405
+ // out to fail another constraint still blocks dispatch.
406
+ const knownInvalid = formKey.validity !== null && formKey.validity !== 'valid';
407
+ const requiredAndEmpty = formKey.required && this._normalizeEmpty(formKey.value) === null;
408
+ return knownInvalid || requiredAndEmpty;
409
+ });
410
+ }
411
+
412
+ /**
413
+ * Whether the reset button should be enabled. True when the form has
414
+ * diverged from its initial state (so the user can always return to
415
+ * defaults — even if the dirty value lives behind a now-disabled field),
416
+ * OR when any non-disabled field has a current value or captured initial
417
+ * value (covers pre-filled forms and user-cleared-back-to-empty cases).
418
+ * @returns {boolean}
419
+ * @private
420
+ */
421
+ _hasResetableState() {
422
+ // Form is dirty — always allow Reset, even if the dirt is on a field
423
+ // that has since been disabled. Without this branch, the user would
424
+ // have no UI path to return the form to its initial state.
425
+ if (!this._isInitialState) {
426
+ return true;
427
+ }
428
+
429
+ return Object.keys(this.formState).some((key) => {
430
+ if (this._isNameDisabled(key)) {
431
+ return false;
432
+ }
433
+ const current = this._normalizeEmpty(this.formState[key].value);
434
+ const initial = this._normalizeEmpty(this._initialValues[key]);
435
+ return current !== null || initial !== null;
436
+ });
437
+ }
438
+
439
+ /**
440
+ * Collapse empty representations to a single canonical `null`.
441
+ *
442
+ * `_addElementToState` captures `null` for a field that mounts without a
443
+ * `value` attribute (`element.value || element.getAttribute('value')` is
444
+ * falsy → resolves to `null`), but `sharedInputListener` later stores the
445
+ * raw `event.target.value` — which is `''` for a user-cleared text input.
446
+ * Without this normalization, backspacing back to empty would taint the
447
+ * form forever (`'' !== null`) and Reset would stay enabled with nothing
448
+ * to actually reset.
449
+ *
450
+ * `''`, `undefined`, and `[]` all collapse to `null`. The empty-array case
451
+ * covers checkbox-group, radio-group, and multiselect, where `[]` means
452
+ * "no selection" — semantically the same as `null`/`''`. Genuine values
453
+ * — including `0`, `false`, non-empty strings, and non-empty arrays —
454
+ * pass through unchanged so number, boolean, and populated multi-value
455
+ * fields still compare correctly.
456
+ * @param {*} value - Value to normalize.
457
+ * @returns {*}
458
+ * @private
459
+ */
460
+ _normalizeEmpty(value) {
461
+ if (value === '' || value === undefined) {
462
+ return null;
463
+ }
464
+ if (Array.isArray(value) && value.length === 0) {
465
+ return null;
466
+ }
467
+ return value;
468
+ }
469
+
310
470
  /**
311
471
  * Infer validity status based on current formState.
472
+ *
473
+ * Validity stays `null` while the form is in its initial state — this is
474
+ * the "stay quiet until the user interacts" UX contract that consumers
475
+ * depend on to delay error indicators. Code that needs the raw
476
+ * constraint-validation result regardless of interaction (e.g.,
477
+ * submit-button enablement) should call `_isFormValid()` directly.
312
478
  * @private
313
479
  */
314
480
  _calculateValidity() {
315
481
  if (this.isInitialState) {
316
482
  this._validity = null;
317
483
  } else {
318
- // go through validity states and return the first invalid state (if any)
319
- const invalidKey = Object.keys(this.formState).
320
- find((key) => {
321
- const formKey = this.formState[key];
322
- // these are NOT extra parens
323
- // eslint-disable-next-line no-extra-parens
324
- return (formKey.validity !== 'valid' && formKey.required) || (formKey.validity !== 'valid' && formKey.value !== null);
325
- });
326
- this._validity = invalidKey ? 'invalid' : 'valid';
484
+ this._validity = this._isFormValid() ? 'valid' : 'invalid';
327
485
  }
328
486
  }
329
487
 
@@ -339,20 +497,38 @@ class AuroForm extends LitElement {
339
497
  }
340
498
 
341
499
  /**
342
- * Determines whether the form is in its initial (untouched) state and updates `_isInitialState` accordingly.
500
+ * Determines whether the form is in its initial (untouched) state.
501
+ *
502
+ * A field is tainted if either:
503
+ * - its value differs from the value captured on first render, OR
504
+ * - its validity is failing (anything other than `null` or `'valid'`).
505
+ *
506
+ * Validity acts as a backup signal: it catches users who interact with a
507
+ * field without changing its value (e.g., focusing and blurring a required
508
+ * field). We skip `null` (not yet validated) and `'valid'` (the default
509
+ * after Auro's auto-validation on mount) because neither proves the user
510
+ * touched anything.
343
511
  * @returns {void}
344
512
  * @private
345
513
  */
346
514
  _setInitialState() {
347
- const anyTainted = Object.keys(this.formState).some((key) => this.formState[key].validity !== null || this.formState[key].value !== null);
515
+ const anyTainted = Object.keys(this.formState).some((key) => {
516
+ // Normalize empty values so a freshly-captured `null` (no `value`
517
+ // attribute at mount) and a user-cleared `''` (input emptied via
518
+ // backspace) compare equal. Without this, backspacing back to an
519
+ // empty field leaves the form permanently tainted.
520
+ const initialValue = this._normalizeEmpty(this._initialValues[key]);
521
+ const currentValue = this._normalizeEmpty(this.formState[key].value);
522
+ const fieldValidity = this.formState[key].validity;
523
+ // eslint-disable-next-line no-extra-parens
524
+ return currentValue !== initialValue || (fieldValidity !== null && fieldValidity !== 'valid');
525
+ });
348
526
 
349
527
  this._isInitialState = !anyTainted;
350
-
351
- this._resetElements.forEach((resetElement) => {
352
- if (resetElement.hasAttribute("disabled")) {
353
- resetElement.removeAttribute("disabled");
354
- }
355
- });
528
+ // Button state is owned by setDisabledStateOnButtons (called from updated()
529
+ // and reset()). Touching resetElement.disabled here causes a visible flicker
530
+ // in reset(), where the final setDisabledStateOnButtons is deferred behind
531
+ // an extra updateComplete.
356
532
  }
357
533
 
358
534
  /**
@@ -370,19 +546,19 @@ class AuroForm extends LitElement {
370
546
  */
371
547
  setDisabledStateOnButtons() {
372
548
  this._resetElements.forEach((element) => {
373
- if (this.isInitialState) {
374
- element.setAttribute("disabled", "");
375
- } else {
376
- element.removeAttribute("disabled");
377
- }
549
+ // Reset is meaningful whenever any non-disabled field has a current
550
+ // value OR a captured default value — i.e., whenever the click would
551
+ // either clear something or restore a default.
552
+ element.disabled = !this._hasResetableState();
378
553
  });
379
554
 
380
555
  this._submitElements.forEach((element) => {
381
- if (this.isInitialState || this.validity !== "valid") {
382
- element.setAttribute("disabled", "");
383
- } else {
384
- element.removeAttribute("disabled");
385
- }
556
+ // Submit enablement reads raw validity (not the gated public getter)
557
+ // so a pre-filled valid form is submittable at first render — the
558
+ // public `validity` stays `null` during initial state to keep error
559
+ // indicators quiet until the user interacts, but the button decision
560
+ // bypasses that gate.
561
+ element.disabled = !this._isFormValid();
386
562
  });
387
563
  }
388
564
 
@@ -428,9 +604,18 @@ class AuroForm extends LitElement {
428
604
  value: element.value || element.getAttribute('value'),
429
605
  validity: element.validity || null,
430
606
  required: element.hasAttribute('required'),
431
- // element
607
+ disabled: element.hasAttribute('disabled'),
432
608
  };
433
609
 
610
+ // Capture the initial (default) value once per name. Use `in` rather
611
+ // than `??=` so a captured `null` (an empty field at first sight) is
612
+ // preserved across rename/slot/reset cycles — `??=` would treat the
613
+ // stored `null` as nullish and overwrite it with whatever value the
614
+ // field has now, defeating the `current !== initial` taint check.
615
+ if (!(targetName in this._initialValues)) {
616
+ this._initialValues[targetName] = this.formState[targetName].value;
617
+ }
618
+
434
619
  this._elements.push(element);
435
620
  }
436
621
 
@@ -467,6 +652,15 @@ class AuroForm extends LitElement {
467
652
  }
468
653
  });
469
654
 
655
+ // Drop captured initial values for fields that no longer exist in the form.
656
+ // Rename migration in _handleAttributeMutations has already re-keyed surviving
657
+ // fields, so anything left here is a field that was removed from the DOM.
658
+ for (const key of Object.keys(this._initialValues)) {
659
+ if (!(key in this.formState)) {
660
+ delete this._initialValues[key];
661
+ }
662
+ }
663
+
470
664
  this.dispatchEvent(new Event('change', {
471
665
  bubbles: true,
472
666
  composed: true,
@@ -511,16 +705,21 @@ class AuroForm extends LitElement {
511
705
  * @returns {Promise<void>}
512
706
  */
513
707
  async submit() {
514
- // Force validation on ALL elements
515
- this._elements.forEach((element) => {
516
- element.validate(true);
517
- });
708
+ // Force validation on all enabled elements. Disabled fields are skipped
709
+ // because disabled controls are not validated nor submitted per the HTML spec.
710
+ this._elements.
711
+ filter((element) => !this._isDisabled(element)).
712
+ forEach((element) => {
713
+ element.validate(true);
714
+ });
518
715
 
519
716
  // Wait for validation to complete and formState to update
520
717
  await this.updateComplete;
521
718
 
522
- // Only dispatch submit event if form is valid
523
- if (this.validity === 'valid') {
719
+ // Gate on raw constraint-validation rather than the public `validity`
720
+ // getter (which is `null` while in initial state). A pre-filled valid
721
+ // form should be submittable without a prior user edit.
722
+ if (this._isFormValid()) {
524
723
  this.dispatchEvent(new CustomEvent('submit', {
525
724
  bubbles: true,
526
725
  composed: true,
@@ -590,7 +789,14 @@ class AuroForm extends LitElement {
590
789
  this._addElementToState(event.target);
591
790
  }
592
791
 
593
- this.formState[targetName].validity = event.detail.validity;
792
+ // `auroFormElement-validated` can fire with `detail.validity === undefined`
793
+ // when auro-input's updated() lifecycle invokes `validate()` mid-edit but
794
+ // the validation framework's gating conditions (not focused, touched-or-
795
+ // has-value) aren't met — the dispatch still fires, just with the current
796
+ // (untouched) validity. Normalize to `null` so the rest of the form treats
797
+ // "no known status" identically whether it came from `_addElementToState`
798
+ // at mount or from this passthrough mid-typing.
799
+ this.formState[targetName].validity = event.detail.validity ?? null;
594
800
  this._calculateValidity();
595
801
  this.requestUpdate('formState');
596
802
  }
@@ -601,10 +807,23 @@ class AuroForm extends LitElement {
601
807
  * @private
602
808
  */
603
809
  handleKeyDown(event) {
604
- if (event.key === 'Enter' && this.isFormElement(event.target)) {
810
+
811
+ /**
812
+ * Use `currentTarget` because it is the tracked form element
813
+ * handling the event, while `target` may be an inner radio,
814
+ * checkbox, or counter inside that form control.
815
+ */
816
+ const formElement = event.currentTarget;
817
+
818
+ if (event.key === 'Enter' && this.isFormElement(formElement)) {
819
+ // Disabled controls do not submit a form natively.
820
+ if (this._isDisabled(formElement)) {
821
+ return;
822
+ }
823
+
605
824
  // Don't submit if it's a textarea (allow new lines)
606
- if (event.target.tagName.toLowerCase() === 'textarea' ||
607
- event.target.hasAttribute('textarea')) {
825
+ if (formElement.tagName.toLowerCase() === 'textarea' ||
826
+ formElement.hasAttribute('textarea')) {
608
827
  return;
609
828
  }
610
829
 
@@ -633,6 +852,121 @@ class AuroForm extends LitElement {
633
852
  });
634
853
  }
635
854
 
855
+ /**
856
+ * Handle batched MutationObserver records for `disabled` and `name`
857
+ * attribute changes on tracked form elements. A `name` change invalidates
858
+ * the formState keying — we resolve it by re-initializing state. A `disabled`
859
+ * change simply needs a re-render (so `value` / `validity` getters re-evaluate)
860
+ * and a refresh of the submit/reset button enablement.
861
+ * @param {MutationRecord[]} mutations - The batched mutation records.
862
+ * @returns {void}
863
+ * @private
864
+ */
865
+ _handleAttributeMutations(mutations) {
866
+ // Only mutations on tracked FORM elements matter here. The same observer
867
+ // also sees attribute changes on the submit/reset buttons (which this
868
+ // component itself toggles via `setDisabledStateOnButtons`); reacting to
869
+ // those would create an infinite observer/update loop.
870
+ const relevant = mutations.filter((mutation) => this.isFormElement(mutation.target));
871
+ if (relevant.length === 0) {
872
+ return;
873
+ }
874
+
875
+ const renameMutations = relevant.filter((mutation) => mutation.attributeName === 'name');
876
+ if (renameMutations.length > 0) {
877
+ // Migrate each renamed field's captured initial value from the old key
878
+ // to the new key before `initializeState()` re-runs `_addElementToState`.
879
+ // Without this, the new-name lookup in `_initialValues` would miss, the
880
+ // field's current (possibly user-edited) value would be captured as the
881
+ // new initial, and the form would incorrectly flip back to its initial
882
+ // state. The old key would also leak in `_initialValues` indefinitely.
883
+ renameMutations.forEach((mutation) => {
884
+ const oldName = mutation.oldValue;
885
+ const newName = mutation.target.getAttribute('name');
886
+ if (!oldName || oldName === newName) {
887
+ return;
888
+ }
889
+ if (newName === null) {
890
+ // `name` attribute removed — field will fall out of formState on re-init.
891
+ // Drop its captured initial so it doesn't leak in _initialValues.
892
+ delete this._initialValues[oldName];
893
+ return;
894
+ }
895
+ if (oldName in this._initialValues) {
896
+ this._initialValues[newName] = this._initialValues[oldName];
897
+ delete this._initialValues[oldName];
898
+ }
899
+ });
900
+ // initializeState() rebuilds formState from scratch (re-keying any
901
+ // renamed element) and also dispatches `change` + refreshes button state.
902
+ // We also re-run _attachEventListeners() because elements that previously
903
+ // had no `name` were skipped by queryAuroElements() (which selects
904
+ // `[name]`) and therefore never received input/validation/keydown
905
+ // listeners. Re-attaching is safe — the listener-removal step inside
906
+ // _attachEventListeners() prevents duplicates on already-wired elements.
907
+ this.initializeState();
908
+ this._attachEventListeners();
909
+ return;
910
+ }
911
+
912
+ // Refresh the cached `disabled` flag on each affected formState entry
913
+ // before the re-render, so getters that read `_isNameDisabled` see the
914
+ // current attribute state in the same tick.
915
+ relevant
916
+ .filter((mutation) => mutation.attributeName === 'disabled')
917
+ .forEach((mutation) => {
918
+ const name = mutation.target.getAttribute('name');
919
+ if (name && this.formState[name]) {
920
+ this.formState[name].disabled = mutation.target.hasAttribute('disabled');
921
+ }
922
+ });
923
+
924
+ this.requestUpdate('formState');
925
+ this.setDisabledStateOnButtons();
926
+ }
927
+
928
+ /**
929
+ * @returns {void}
930
+ */
931
+ connectedCallback() {
932
+ super.connectedCallback();
933
+
934
+ // One observer rooted at the host catches `disabled` / `name` changes on
935
+ // any tracked form element (light-DOM children, including those nested in
936
+ // wrapper elements). Cheaper than allocating an observer per element and
937
+ // resilient to runtime DOM mutations.
938
+ if (!this._attributeObserver) {
939
+ this._attributeObserver = new MutationObserver(this._handleAttributeMutations);
940
+ }
941
+
942
+ this._attributeObserver.observe(this, {
943
+ attributes: true,
944
+ subtree: true,
945
+ attributeOldValue: true,
946
+ attributeFilter: [
947
+ 'disabled',
948
+ 'name'
949
+ ]
950
+ });
951
+ }
952
+
953
+ /**
954
+ * @returns {void}
955
+ */
956
+ disconnectedCallback() {
957
+ // Disconnect everything we own to avoid leaking observers (and the strong
958
+ // refs they hold to DOM nodes) past the form's lifetime.
959
+ this._attributeObserver?.disconnect();
960
+ this.mutationObservers.forEach((observer) => observer.disconnect());
961
+ this.mutationObservers = [];
962
+ // Intentionally do NOT clear _initialValues here. Slot-moves trigger a
963
+ // disconnect/reconnect cycle; clearing would cause the next initializeState
964
+ // to capture the user's edited values as the new "initial" and silently
965
+ // flip the form back to its initial state.
966
+
967
+ super.disconnectedCallback();
968
+ }
969
+
636
970
  /**
637
971
  * @param {import('lit').PropertyValues} _changedProperties - Map of changed properties with their previous values.
638
972
  * @returns {void}
@@ -4,7 +4,7 @@
4
4
  <div class="scrollWrapper">
5
5
  The `auro-input` component renders a native HTML <code>&lt;input&gt;</code> element inside its shadow DOM and augments it with custom label, help text, and validation UI. This page documents the ARIA semantics, screen reader announcements, and other accessibility behaviors built into the component.
6
6
 
7
- For keyboard interaction details, see the <auro-hyperlink href="./keyboard-behavior.html">Keyboard Behavior</auro-hyperlink> page.
7
+ For keyboard interaction details, see the <auro-hyperlink href="keyboard-behavior">Keyboard Behavior</auro-hyperlink> page.
8
8
 
9
9
  <auro-header level="2" id="ariaRolesAndAttributes">ARIA Roles and Attributes</auro-header>
10
10
  <auro-header level="3" id="inputElement">Input Element</auro-header>