@zeyos/zx 4.4.0 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (252) hide show
  1. package/CHANGELOG.md +447 -0
  2. package/README.md +44 -11
  3. package/dist/types/components/account-menu/account-menu.d.ts +8 -0
  4. package/dist/types/components/activity-list/activity-list.d.ts +31 -1
  5. package/dist/types/components/app-icon/app-icon.d.ts +14 -2
  6. package/dist/types/components/app-sidebar/app-sidebar.d.ts +293 -17
  7. package/dist/types/components/avatar/avatar.d.ts +46 -7
  8. package/dist/types/components/badge/badge.d.ts +21 -1
  9. package/dist/types/components/button/button.d.ts +15 -0
  10. package/dist/types/components/calendar/calendar.d.ts +67 -5
  11. package/dist/types/components/card-view/card-view.d.ts +7 -4
  12. package/dist/types/components/card-view/record-card.d.ts +11 -0
  13. package/dist/types/components/chart/chart.d.ts +10 -0
  14. package/dist/types/components/checklist/checklist.d.ts +9 -0
  15. package/dist/types/components/code-editor/code-editor.d.ts +6 -17
  16. package/dist/types/components/context-menu/context-menu.d.ts +6 -2
  17. package/dist/types/components/data-filter/data-filter.d.ts +4 -4
  18. package/dist/types/components/datebox/date-range-box.d.ts +5 -1
  19. package/dist/types/components/datebox/datebox.d.ts +7 -2
  20. package/dist/types/components/dialog/dialog.d.ts +46 -6
  21. package/dist/types/components/dropdown/dropdown.d.ts +24 -9
  22. package/dist/types/components/entity-ref/entity-ref.d.ts +2 -2
  23. package/dist/types/components/field/field.d.ts +54 -2
  24. package/dist/types/components/fieldset/external-adapter.d.ts +83 -0
  25. package/dist/types/components/fieldset/fieldset.d.ts +26 -1
  26. package/dist/types/components/file-item/file-item.d.ts +34 -6
  27. package/dist/types/components/file-list/file-list.d.ts +35 -3
  28. package/dist/types/components/filter/filter.d.ts +127 -3
  29. package/dist/types/components/filter-panel/filter-panel.d.ts +36 -7
  30. package/dist/types/components/form/form.d.ts +12 -1
  31. package/dist/types/components/kanban-view/kanban-card.d.ts +8 -0
  32. package/dist/types/components/launcher/launcher.d.ts +268 -15
  33. package/dist/types/components/list-toolbar/list-toolbar.d.ts +16 -7
  34. package/dist/types/components/menu-button/menu-button.d.ts +39 -5
  35. package/dist/types/components/message/message.d.ts +132 -17
  36. package/dist/types/components/modal/modal.d.ts +43 -2
  37. package/dist/types/components/module-bar/module-bar.d.ts +395 -0
  38. package/dist/types/components/pagination/pagination.d.ts +17 -3
  39. package/dist/types/components/select/color.d.ts +12 -0
  40. package/dist/types/components/select/glyphs.d.ts +124 -0
  41. package/dist/types/components/select/select.d.ts +17 -3
  42. package/dist/types/components/sheet/sheet.d.ts +15 -0
  43. package/dist/types/components/stat-tile/stat-tile.d.ts +16 -5
  44. package/dist/types/components/tabbox/tabbox.d.ts +9 -0
  45. package/dist/types/components/table/table.d.ts +311 -18
  46. package/dist/types/components/tag-picker/tag-picker.d.ts +21 -4
  47. package/dist/types/components/tree/tree.d.ts +13 -0
  48. package/dist/types/components/view/record-view.d.ts +59 -2
  49. package/dist/types/components/view-settings/view-settings.d.ts +879 -0
  50. package/dist/types/components/workspace-switcher/workspace-switcher.d.ts +327 -0
  51. package/dist/types/core/breakpoint.d.ts +1 -1
  52. package/dist/types/core/component.d.ts +12 -2
  53. package/dist/types/core/i18n.d.ts +27 -3
  54. package/dist/types/core/icons.d.ts +23 -25
  55. package/dist/types/core/keyboard.d.ts +98 -1
  56. package/dist/types/core/messages.d.ts +382 -0
  57. package/dist/types/core/motion.d.ts +59 -0
  58. package/dist/types/core/overlay-host.d.ts +42 -1
  59. package/dist/types/core/overlay-stack.d.ts +260 -0
  60. package/dist/types/index.d.ts +10 -5
  61. package/dist/types/internal/app-rail.d.ts +38 -0
  62. package/dist/types/internal/menu-items.d.ts +71 -3
  63. package/dist/types/zeyos/connect.d.ts +6 -0
  64. package/dist/types/zeyos/form.d.ts +50 -9
  65. package/dist/types/zeyos/index.d.ts +2 -1
  66. package/dist/types/zeyos/launcher.d.ts +9 -0
  67. package/dist/types/zeyos/module-bar.d.ts +176 -0
  68. package/dist/types/zeyos/query.d.ts +110 -5
  69. package/dist/types/zeyos/schema.d.ts +24 -1
  70. package/dist/types/zeyos/table.d.ts +345 -22
  71. package/dist/types/zeyos/view.d.ts +26 -2
  72. package/dist/zx-zeyos.esm.js +6178 -1008
  73. package/dist/zx-zeyos.esm.min.js +53 -5
  74. package/dist/zx.css +2861 -827
  75. package/dist/zx.esm.js +8909 -1480
  76. package/dist/zx.esm.min.js +55 -7
  77. package/dist/zx.global.js +8911 -1482
  78. package/dist/zx.global.min.js +55 -7
  79. package/dist/zx.min.css +1 -1
  80. package/docs/api.json +1079 -117
  81. package/docs/llms.md +956 -154
  82. package/docs/llms.txt +9 -5
  83. package/package.json +1 -1
  84. package/src/components/account-menu/account-menu.css +35 -10
  85. package/src/components/account-menu/account-menu.js +12 -0
  86. package/src/components/activity-item/activity-item.css +1 -1
  87. package/src/components/activity-item/activity-item.js +3 -0
  88. package/src/components/activity-list/activity-list.css +38 -3
  89. package/src/components/activity-list/activity-list.js +61 -1
  90. package/src/components/app-icon/app-icon.css +14 -3
  91. package/src/components/app-icon/app-icon.js +11 -1
  92. package/src/components/app-rail/app-rail.css +59 -79
  93. package/src/components/app-sidebar/app-sidebar.css +304 -168
  94. package/src/components/app-sidebar/app-sidebar.js +667 -46
  95. package/src/components/aurora/aurora.css +5 -3
  96. package/src/components/avatar/avatar.css +32 -7
  97. package/src/components/avatar/avatar.js +72 -8
  98. package/src/components/badge/badge.css +54 -2
  99. package/src/components/badge/badge.js +29 -2
  100. package/src/components/breadcrumb/breadcrumb.css +1 -1
  101. package/src/components/button/button.css +67 -9
  102. package/src/components/button/button.js +38 -0
  103. package/src/components/calendar/calendar.css +47 -47
  104. package/src/components/calendar/calendar.js +26 -52
  105. package/src/components/card/card.css +4 -4
  106. package/src/components/card-view/card-view.css +14 -12
  107. package/src/components/card-view/card-view.js +13 -9
  108. package/src/components/card-view/record-card.js +14 -3
  109. package/src/components/chart/chart.css +4 -1
  110. package/src/components/chart/chart.js +12 -0
  111. package/src/components/check-button/check-button.css +10 -2
  112. package/src/components/checklist/checklist.css +9 -2
  113. package/src/components/checklist/checklist.js +11 -5
  114. package/src/components/checklist/field-adapter.js +3 -1
  115. package/src/components/code-editor/code-editor.css +7 -6
  116. package/src/components/code-editor/code-editor.js +19 -1
  117. package/src/components/code-editor/field-adapter.js +9 -2
  118. package/src/components/context-menu/context-menu.css +9 -4
  119. package/src/components/context-menu/context-menu.js +8 -2
  120. package/src/components/copy/copy-button.css +6 -0
  121. package/src/components/copy/copy-input.css +9 -4
  122. package/src/components/data-filter/data-filter.css +5 -4
  123. package/src/components/data-filter/data-filter.js +2 -2
  124. package/src/components/date-picker/date-picker.css +6 -6
  125. package/src/components/date-picker/date-range-picker.css +3 -3
  126. package/src/components/date-picker/time-picker.css +3 -3
  127. package/src/components/datebox/date-range-box.css +79 -2
  128. package/src/components/datebox/date-range-box.js +13 -2
  129. package/src/components/datebox/datebox.css +79 -2
  130. package/src/components/datebox/datebox.js +13 -2
  131. package/src/components/dialog/dialog.css +26 -0
  132. package/src/components/dialog/dialog.js +99 -17
  133. package/src/components/dock/dock.css +5 -4
  134. package/src/components/dropdown/dropdown.css +1 -1
  135. package/src/components/dropdown/dropdown.js +159 -72
  136. package/src/components/empty-state/empty-state.css +2 -2
  137. package/src/components/entity-ref/entity-ref.css +1 -1
  138. package/src/components/entity-ref/entity-ref.js +1 -1
  139. package/src/components/field/field.css +28 -9
  140. package/src/components/field/field.js +101 -29
  141. package/src/components/field-upload/field-upload.css +1 -1
  142. package/src/components/fieldset/external-adapter.js +363 -0
  143. package/src/components/fieldset/fieldset.css +5 -3
  144. package/src/components/fieldset/fieldset.js +38 -64
  145. package/src/components/file-item/file-item.js +144 -10
  146. package/src/components/file-list/file-list.js +144 -21
  147. package/src/components/filter/filter.css +86 -2
  148. package/src/components/filter/filter.js +307 -11
  149. package/src/components/filter-panel/filter-panel.css +1 -1
  150. package/src/components/filter-panel/filter-panel.js +149 -32
  151. package/src/components/finder/finder.css +16 -14
  152. package/src/components/form/form.css +5 -4
  153. package/src/components/form/form.js +11 -2
  154. package/src/components/grid/grid.css +1 -1
  155. package/src/components/groupbox/groupbox.css +1 -1
  156. package/src/components/kanban-view/kanban-card.js +11 -3
  157. package/src/components/kanban-view/kanban-view.css +42 -18
  158. package/src/components/kanban-view/kanban-view.js +9 -6
  159. package/src/components/launcher/launcher.css +108 -26
  160. package/src/components/launcher/launcher.js +530 -42
  161. package/src/components/list-toolbar/list-toolbar.css +13 -8
  162. package/src/components/list-toolbar/list-toolbar.js +40 -14
  163. package/src/components/master-panel/master-panel.css +2 -0
  164. package/src/components/master-panel/master-panel.js +0 -1
  165. package/src/components/menu-button/menu-button.css +72 -4
  166. package/src/components/menu-button/menu-button.js +160 -18
  167. package/src/components/message/message.css +33 -3
  168. package/src/components/message/message.js +264 -22
  169. package/src/components/modal/modal.css +1 -1
  170. package/src/components/modal/modal.js +83 -15
  171. package/src/components/module-bar/module-bar.css +486 -0
  172. package/src/components/module-bar/module-bar.js +1074 -0
  173. package/src/components/multi-value-editor/multi-value-editor.css +1 -1
  174. package/src/components/navigation-bar/navigation-bar.css +18 -9
  175. package/src/components/number-field/field-adapter.js +3 -1
  176. package/src/components/number-field/number-field.css +70 -0
  177. package/src/components/number-field/number-field.js +2 -0
  178. package/src/components/pagination/pagination.css +13 -11
  179. package/src/components/pagination/pagination.js +20 -4
  180. package/src/components/panel/panel.css +4 -3
  181. package/src/components/questionnaire/questionnaire.css +4 -4
  182. package/src/components/rating/field-adapter.js +2 -1
  183. package/src/components/rating/rating.css +8 -1
  184. package/src/components/search/search.css +4 -1
  185. package/src/components/select/color.js +22 -0
  186. package/src/components/select/field-adapter.js +2 -0
  187. package/src/components/select/glyphs.js +165 -0
  188. package/src/components/select/select.css +138 -45
  189. package/src/components/select/select.js +92 -47
  190. package/src/components/sheet/sheet.css +12 -9
  191. package/src/components/sheet/sheet.js +74 -32
  192. package/src/components/slider/field-adapter.js +6 -1
  193. package/src/components/slider/slider.css +18 -4
  194. package/src/components/slider/slider.js +1 -1
  195. package/src/components/sort-control/sort-control.css +4 -1
  196. package/src/components/split-view/split-view.css +1 -1
  197. package/src/components/stat-tile/stat-tile.css +42 -47
  198. package/src/components/stat-tile/stat-tile.js +17 -6
  199. package/src/components/stepper/stepper.css +1 -1
  200. package/src/components/tabbox/tabbox.css +41 -32
  201. package/src/components/tabbox/tabbox.js +5 -1
  202. package/src/components/table/table.css +187 -37
  203. package/src/components/table/table.js +718 -73
  204. package/src/components/tag-picker/field-adapter.js +2 -1
  205. package/src/components/tag-picker/tag-picker.css +83 -5
  206. package/src/components/tag-picker/tag-picker.js +25 -8
  207. package/src/components/timebox/timebox.css +22 -2
  208. package/src/components/toggle/toggle.css +12 -1
  209. package/src/components/toolbar/toolbar.js +2 -2
  210. package/src/components/tooltip/tooltip.css +4 -0
  211. package/src/components/tooltip/tooltip.js +44 -19
  212. package/src/components/tree/tree.css +26 -17
  213. package/src/components/tree/tree.js +31 -0
  214. package/src/components/value-list/value-list.css +3 -3
  215. package/src/components/view/record-view.css +1 -1
  216. package/src/components/view/record-view.js +178 -18
  217. package/src/components/view-settings/view-settings.css +285 -0
  218. package/src/components/view-settings/view-settings.js +1830 -0
  219. package/src/components/workspace-switcher/workspace-switcher.css +188 -0
  220. package/src/components/workspace-switcher/workspace-switcher.js +463 -0
  221. package/src/core/breakpoint.js +1 -1
  222. package/src/core/component.js +88 -17
  223. package/src/core/fontawesome.js +5 -1
  224. package/src/core/i18n.js +47 -4
  225. package/src/core/icons.js +204 -61
  226. package/src/core/keyboard.js +206 -11
  227. package/src/core/messages.js +427 -0
  228. package/src/core/motion.js +147 -0
  229. package/src/core/overlay-host.js +77 -3
  230. package/src/core/overlay-stack.js +487 -0
  231. package/src/core/position.js +22 -3
  232. package/src/index.js +10 -5
  233. package/src/internal/app-rail.js +135 -55
  234. package/src/internal/menu-items.js +61 -3
  235. package/src/zeyos/connect.js +6 -2
  236. package/src/zeyos/form.js +69 -27
  237. package/src/zeyos/index.js +4 -1
  238. package/src/zeyos/launcher.js +8 -3
  239. package/src/zeyos/module-bar.js +181 -0
  240. package/src/zeyos/query.js +197 -13
  241. package/src/zeyos/schema.js +144 -27
  242. package/src/zeyos/table.js +287 -84
  243. package/src/zeyos/view.js +49 -93
  244. package/styles/base.css +72 -1
  245. package/styles/icons.css +11 -1
  246. package/styles/print.css +1 -1
  247. package/styles/tokens/dark.css +117 -37
  248. package/styles/tokens/global.css +121 -27
  249. package/styles/tokens/semantic.css +140 -43
  250. package/styles/tokens/themes.css +7 -6
  251. package/styles/zx.css +3 -1
  252. package/styles/tokens/density.css +0 -9
@@ -4,7 +4,10 @@ import { uid } from '../../core/util.js';
4
4
 
5
5
  /**
6
6
  * @typedef {Object} FieldOptions
7
- * @property {string|null} [id=null] Control ID and logical field identifier.
7
+ * @property {string|null} [id=null] DOM id of the control. Generated when omitted; a Fieldset
8
+ * derives it from its `idPrefix` and the field key, so a key never becomes a DOM id by itself.
9
+ * @property {string|null} [name=null] Field name: the `name` of a native control and the key the
10
+ * field is filed under. A Fieldset sets it to the field key; a standalone field falls back to `id`.
8
11
  * @property {string} [type='text'] Registered field type.
9
12
  * @property {string} [label=''] Visible label.
10
13
  * @property {string} [description=''] Supporting description.
@@ -12,6 +15,9 @@ import { uid } from '../../core/util.js';
12
15
  * @property {string} [placeholder=''] Control placeholder.
13
16
  * @property {boolean} [required=false] Whether a value is required.
14
17
  * @property {boolean} [disabled=false] Whether the control is disabled.
18
+ * @property {boolean} [readonly=false] Whether the value is read-only: shown and focusable, not
19
+ * editable. Types whose control has no read-only state (`checkbox`, `select`, `optionlist`,
20
+ * `zxselect`, `date`, `time`, …) ignore it.
15
21
  * @property {string[]|Record<string, string>|null} [options=null] Select or option-list values.
16
22
  * @property {'stack'|'inline'} [layout='stack'] Label/control layout.
17
23
  * @property {Record<string, unknown>} [props={}] Adapter-specific control properties.
@@ -27,7 +33,12 @@ import { uid } from '../../core/util.js';
27
33
  * @property {(value: unknown, options?: {silent?: boolean}) => void} set Set the current value.
28
34
  * @property {() => void} focus Focus the control.
29
35
  * @property {(disabled: boolean) => void} setDisabled Set disabled state.
36
+ * @property {(readonly: boolean) => void} [setReadonly] Set read-only state; left out when the
37
+ * control has none.
30
38
  * @property {() => void} [destroy] Release adapter-owned resources.
39
+ * @property {Element} [labelTarget] The element the field's label, description, and validity
40
+ * belong to when it is not `el` — the text input inside a composite control. Left out, a
41
+ * wrapper that cannot be labelled hands them to the first unnamed form control inside it.
31
42
  */
32
43
 
33
44
  /** @typedef {(field: Field, options: Readonly<FieldOptions>) => FieldAdapter} FieldAdapterFactory */
@@ -36,6 +47,7 @@ import { uid } from '../../core/util.js';
36
47
  /**
37
48
  * One labelled form control backed by a registered adapter.
38
49
  * @fires Field#change
50
+ * @fires Field#input
39
51
  * @fires Field#invalid
40
52
  * @extends {Component<FieldOptions>}
41
53
  */
@@ -45,6 +57,7 @@ export class Field extends Component {
45
57
  /** @type {FieldOptions & {msg: Record<string, string>}} */
46
58
  static defaults = {
47
59
  id: null,
60
+ name: null,
48
61
  type: 'text',
49
62
  label: '',
50
63
  description: '',
@@ -52,6 +65,7 @@ export class Field extends Component {
52
65
  placeholder: '',
53
66
  required: false,
54
67
  disabled: false,
68
+ readonly: false,
55
69
  options: null,
56
70
  layout: 'stack',
57
71
  props: {},
@@ -88,9 +102,10 @@ export class Field extends Component {
88
102
  return Field.#adapters.has(String(type).trim().toLowerCase());
89
103
  }
90
104
 
91
- // `adapter` (FieldAdapter), `initialValue`, and `controlId` are assigned in render();
92
- // declaring them as class fields would re-initialize them to undefined after the base
93
- // constructor (which calls render()) returns — see AGENTS.md.
105
+ // `adapter` (FieldAdapter), `initialValue`, `controlId` (the control's DOM id), and `name` (the
106
+ // field name, '' when there is none) are assigned in render(); declaring them as class fields
107
+ // would re-initialize them to undefined after the base constructor (which calls render())
108
+ // returns — see AGENTS.md.
94
109
 
95
110
  /** @returns {HTMLElement} */
96
111
  render() {
@@ -100,11 +115,14 @@ export class Field extends Component {
100
115
  const layout = this.options.layout === 'inline' ? 'inline' : 'stack';
101
116
  this._ownedComponents = new Set();
102
117
  this._ownedNodes = [];
103
- this._rootAttributes = rememberAttributes(root, ['data-layout', 'data-field-type', 'data-disabled', 'data-state']);
118
+ this._rootAttributes = rememberAttributes(root, ['data-layout', 'data-field-type', 'data-disabled', 'data-readonly', 'data-state']);
104
119
  root.dataset.layout = layout;
105
120
  root.dataset.fieldType = type;
106
121
 
122
+ /** @type {string} The control's DOM id: `options.id`, or a generated one. */
107
123
  this.controlId = this.options.id ? String(this.options.id) : uid('zx-field');
124
+ /** @type {string} The field name (`options.name`, else `options.id`), '' when there is none. */
125
+ this.name = String(this.options.name ?? this.options.id ?? '');
108
126
  const labelId = `${this.controlId}-label`;
109
127
  const descriptionId = `${this.controlId}-description`;
110
128
  const highlightId = `${this.controlId}-highlight`;
@@ -148,9 +166,14 @@ export class Field extends Component {
148
166
 
149
167
  const control = this.adapter.el;
150
168
  if (!control.id) control.id = this.controlId;
151
- label.htmlFor = control.id;
152
- if (!isLabelable(control)) control.setAttribute('aria-labelledby', labelId);
153
- this._baseDescribedBy = control.getAttribute('aria-describedby') ?? '';
169
+ // A wrapper (a `custom` adapter's box) cannot carry a name; the control inside it can.
170
+ const labelled = this.adapter.labelTarget
171
+ ?? (isLabelable(control) ? control : unnamedControlWithin(control)) ?? control;
172
+ this._labelled = labelled;
173
+ if (labelled !== control && isLabelable(labelled) && !labelled.id) labelled.id = `${this.controlId}-input`;
174
+ label.htmlFor = isLabelable(labelled) ? labelled.id : control.id;
175
+ if (!isLabelable(labelled)) labelled.setAttribute('aria-labelledby', labelId);
176
+ this._baseDescribedBy = labelled.getAttribute('aria-describedby') ?? '';
154
177
  controlSlot.append(control);
155
178
  this._descriptionId = String(this.options.description ?? '') ? descriptionId : '';
156
179
  this._highlightId = highlightId;
@@ -159,6 +182,8 @@ export class Field extends Component {
159
182
  this.initialValue = initialValue(type, this.options.value);
160
183
  this.adapter.set(this.initialValue, { silent: true });
161
184
  this.setDisabled(Boolean(this.options.disabled));
185
+ if (this.options.readonly) this.setReadonly(true);
186
+ // Adapters whose widget reports its own commits stop the native `change` before this sees it.
162
187
  this.listen(control, 'change', () => {
163
188
  this.emit('change', { value: this.getValue() });
164
189
  });
@@ -217,6 +242,19 @@ export class Field extends Component {
217
242
  return this;
218
243
  }
219
244
 
245
+ /**
246
+ * Makes the value read-only or editable again, where the control has a read-only state (see
247
+ * the `readonly` option); reflected as `data-readonly` on the root either way.
248
+ * @param {boolean} readonly Read-only state.
249
+ * @returns {this}
250
+ */
251
+ setReadonly(readonly) {
252
+ const value = Boolean(readonly);
253
+ this.adapter.setReadonly?.(value);
254
+ this.el.dataset.readonly = String(value);
255
+ return this;
256
+ }
257
+
220
258
  /**
221
259
  * Displays a validation or status message.
222
260
  * @param {string} message Message text.
@@ -231,11 +269,11 @@ export class Field extends Component {
231
269
  this._highlight.hidden = !message;
232
270
  if (normalizedKind === 'danger' && message) {
233
271
  this._highlight.setAttribute('role', 'alert');
234
- this.adapter.el.setAttribute('aria-invalid', 'true');
272
+ this._labelled.setAttribute('aria-invalid', 'true');
235
273
  this.emit('invalid', { message: String(message) });
236
274
  } else {
237
275
  this._highlight.removeAttribute('role');
238
- this.adapter.el.removeAttribute('aria-invalid');
276
+ this._labelled.removeAttribute('aria-invalid');
239
277
  }
240
278
  this._syncDescription(Boolean(message));
241
279
  return this;
@@ -250,7 +288,7 @@ export class Field extends Component {
250
288
  this._highlight.textContent = '';
251
289
  this._highlight.hidden = true;
252
290
  this._highlight.removeAttribute('role');
253
- this.adapter.el.removeAttribute('aria-invalid');
291
+ this._labelled.removeAttribute('aria-invalid');
254
292
  this._syncDescription(false);
255
293
  return this;
256
294
  }
@@ -279,8 +317,8 @@ export class Field extends Component {
279
317
  _syncDescription(highlighted = false) {
280
318
  const ids = [this._baseDescribedBy, this._descriptionId, highlighted ? this._highlightId : '']
281
319
  .flatMap((value) => value.split(/\s+/)).filter(Boolean);
282
- if (ids.length) this.adapter.el.setAttribute('aria-describedby', [...new Set(ids)].join(' '));
283
- else this.adapter.el.removeAttribute('aria-describedby');
320
+ if (ids.length) this._labelled.setAttribute('aria-describedby', [...new Set(ids)].join(' '));
321
+ else this._labelled.removeAttribute('aria-describedby');
284
322
  }
285
323
 
286
324
  /** @returns {void} */
@@ -400,6 +438,22 @@ function isLabelable(element) {
400
438
  return ['BUTTON', 'INPUT', 'METER', 'OUTPUT', 'PROGRESS', 'SELECT', 'TEXTAREA'].includes(element.tagName);
401
439
  }
402
440
 
441
+ /**
442
+ * The first form control inside `root` that nothing names yet — no `aria-label`, no
443
+ * `aria-labelledby`, no `<label>` with any text — or null.
444
+ * @param {Element} root
445
+ * @returns {Element|null}
446
+ */
447
+ function unnamedControlWithin(root) {
448
+ for (const candidate of root.querySelectorAll('input:not([type="hidden"]), select, textarea')) {
449
+ if (candidate.hasAttribute('aria-label') || candidate.hasAttribute('aria-labelledby')) continue;
450
+ const labels = [.../** @type {HTMLInputElement} */ (candidate).labels ?? []];
451
+ if (labels.some((label) => label.textContent?.trim())) continue;
452
+ return candidate;
453
+ }
454
+ return null;
455
+ }
456
+
403
457
  /** @param {HTMLElement} element @param {string[]} names @returns {Map<string, string|null>} */
404
458
  function rememberAttributes(element, names) {
405
459
  return new Map(names.map((name) => [name, element.getAttribute(name)]));
@@ -413,25 +467,32 @@ function restoreAttributes(element, attributes) {
413
467
  }
414
468
  }
415
469
 
416
- /** @param {Readonly<FieldOptions>} options @param {Record<string, unknown>} base @returns {Record<string, unknown>} */
417
- function controlProps(options, base) {
470
+ /**
471
+ * The properties a native control is built with: the adapter's own `base` over the caller's
472
+ * `props`, with the field name as the control's `name` unless `props` gives one.
473
+ * @param {Field} field Field the control belongs to.
474
+ * @param {Readonly<FieldOptions>} options Field options.
475
+ * @param {Record<string, unknown>} base Properties the adapter owns.
476
+ * @returns {Record<string, unknown>}
477
+ */
478
+ function controlProps(field, options, base) {
418
479
  const props = { ...(options.props ?? {}) };
419
480
  for (const key of Object.keys(props)) {
420
481
  if (/^on[a-z]/i.test(key) || key === 'content') delete props[key];
421
482
  }
422
- return { ...props, ...base };
483
+ return { name: field.name || undefined, ...props, ...base };
423
484
  }
424
485
 
425
486
  /** @param {'input'|'textarea'} tag @param {string} type @returns {FieldAdapterFactory} */
426
487
  function textAdapter(tag, type) {
427
- return (_field, options) => {
428
- const el = h(tag, controlProps(options, {
488
+ return (field, options) => {
489
+ const el = h(tag, controlProps(field, options, {
429
490
  type: tag === 'input' ? type : undefined,
430
491
  placeholder: options.placeholder,
431
492
  required: options.required,
432
493
  disabled: options.disabled
433
494
  }));
434
- return elementValueAdapter(el);
495
+ return { ...elementValueAdapter(el), setReadonly: (readonly) => { el.readOnly = readonly; } };
435
496
  };
436
497
  }
437
498
 
@@ -450,7 +511,7 @@ function elementValueAdapter(el) {
450
511
  function numericAdapter(kind) {
451
512
  return (field, options) => {
452
513
  const coerce = kind === 'int' ? coerceInteger : coerceFloat;
453
- const input = h('input', controlProps(options, {
514
+ const input = h('input', controlProps(field, options, {
454
515
  type: 'text',
455
516
  inputMode: kind === 'int' ? 'numeric' : 'decimal',
456
517
  placeholder: options.placeholder,
@@ -468,14 +529,15 @@ function numericAdapter(kind) {
468
529
  });
469
530
  return {
470
531
  ...elementValueAdapter(input),
471
- get: () => coerce(input.value).value
532
+ get: () => coerce(input.value).value,
533
+ setReadonly: (readonly) => { input.readOnly = readonly; }
472
534
  };
473
535
  };
474
536
  }
475
537
 
476
538
  /** @type {FieldAdapterFactory} */
477
- const checkboxAdapter = (_field, options) => {
478
- const input = h('input', controlProps(options, {
539
+ const checkboxAdapter = (field, options) => {
540
+ const input = h('input', controlProps(field, options, {
479
541
  type: 'checkbox',
480
542
  required: options.required,
481
543
  disabled: options.disabled
@@ -490,8 +552,8 @@ const checkboxAdapter = (_field, options) => {
490
552
  };
491
553
 
492
554
  /** @type {FieldAdapterFactory} */
493
- const selectAdapter = (_field, options) => {
494
- const select = h('select', controlProps(options, {
555
+ const selectAdapter = (field, options) => {
556
+ const select = h('select', controlProps(field, options, {
495
557
  required: options.required,
496
558
  disabled: options.disabled
497
559
  }));
@@ -502,12 +564,15 @@ const selectAdapter = (_field, options) => {
502
564
  };
503
565
 
504
566
  /** @type {FieldAdapterFactory} */
505
- const optionListAdapter = (_field, options) => {
567
+ const optionListAdapter = (field, options) => {
506
568
  const root = h('div', {
507
569
  class: 'zx-field__option-list',
508
570
  role: 'radiogroup'
509
571
  });
510
- const name = options.id ? String(options.id) : uid('zx-option-list');
572
+ // The radios are grouped by the control's id, not by the field key: radios outside a <form>
573
+ // share one group per name across the whole document, so the same fieldset mounted twice
574
+ // would otherwise check one copy's option by clicking the other's.
575
+ const name = field.controlId;
511
576
  const radios = normalizeOptions(options.options).map(([value, label], index) => {
512
577
  const input = h('input', {
513
578
  type: 'radio',
@@ -534,8 +599,8 @@ const optionListAdapter = (_field, options) => {
534
599
  };
535
600
 
536
601
  /** @type {FieldAdapterFactory} */
537
- const htmlAdapter = (_field, options) => {
538
- const el = h('div', controlProps(options, { class: 'zx-field__static' }));
602
+ const htmlAdapter = (field, options) => {
603
+ const el = h('div', controlProps(field, options, { class: 'zx-field__static', name: undefined }));
539
604
  let adopted = null;
540
605
  const set = (value) => {
541
606
  restoreAdopted(adopted);
@@ -600,6 +665,13 @@ Field.register('custom', customAdapter);
600
665
  * @type {CustomEvent<{value: unknown}>}
601
666
  */
602
667
 
668
+ /**
669
+ * Live edit before it is committed, from the types whose widget tells the two apart (`code`,
670
+ * `slider`): every keystroke or drag step, where `change` follows only on commit.
671
+ * @event Field#input
672
+ * @type {CustomEvent<{value: unknown}>}
673
+ */
674
+
603
675
  /**
604
676
  * Field validation failure event.
605
677
  * @event Field#invalid
@@ -32,7 +32,7 @@
32
32
 
33
33
  .zx-field-upload__prompt {
34
34
  color: var(--zx-color-text);
35
- font-weight: 600;
35
+ font-weight: var(--zx-weight-medium);
36
36
  }
37
37
 
38
38
  .zx-field-upload__hint,
@@ -0,0 +1,363 @@
1
+ import { isElement } from '../../core/util.js';
2
+
3
+ /** @typedef {import('../field/field.js').Field} Field */
4
+ /** @typedef {import('../field/field.js').FieldAdapter} FieldAdapter */
5
+
6
+ /**
7
+ * An element or component a Fieldset adopts with `addField(id, source)`: anything that renders
8
+ * into an element. Components are read through the methods they have, elements through their
9
+ * native properties.
10
+ * @typedef {Element|{el?: Element, toElement?: () => Element}} ExternalSource
11
+ */
12
+
13
+ /**
14
+ * The value accessor pairs the library's components use, in the order they are tried. A reader
15
+ * counts only when it takes no required argument (`Fieldset.getValue(id)` is not a reader), a
16
+ * writer of another pair only when the value is its sole required argument.
17
+ * - `getValue/setValue`: Field, CodeEditor, Filter, SortControl, Toggle (`getValue()` is the
18
+ * configured on-value or `false`, the same as the `toggle` field type reads).
19
+ * - `get/set`: NumberField, Select, Slider, Rating, CheckButton, Datebox, Timebox, Search,
20
+ * CopyInput, DatePicker and the other pickers.
21
+ * - `getValues/setValues`: Checklist, TagPicker, ValueList, MultiValueEditor, Fieldset, Form.
22
+ * - `isChecked/setChecked`: boolean controls that name their state that way.
23
+ * @type {ReadonlyArray<readonly [string, string]>}
24
+ */
25
+ export const VALUE_METHODS = Object.freeze([
26
+ Object.freeze(['getValue', 'setValue']),
27
+ Object.freeze(['get', 'set']),
28
+ Object.freeze(['getValues', 'setValues']),
29
+ Object.freeze(['isChecked', 'setChecked'])
30
+ ]);
31
+
32
+ /** Value properties a component may expose instead of methods, in the order they are tried. */
33
+ const VALUE_PROPERTIES = Object.freeze(['value', 'values', 'checked']);
34
+
35
+ /** Form controls the fallback disables or makes read-only inside a plain wrapper element. */
36
+ const CONTROLS = 'input, select, textarea, button';
37
+ const TEXT_CONTROLS = 'input, textarea';
38
+
39
+ /**
40
+ * The element a source renders into.
41
+ * @param {ExternalSource} source Element or component.
42
+ * @returns {Element}
43
+ */
44
+ export function externalElement(source) {
45
+ if (isElement(source)) return /** @type {Element} */ (source);
46
+ const component = /** @type {{el?: Element, toElement?: () => Element}} */ (source);
47
+ if (typeof component.toElement === 'function') return component.toElement();
48
+ if (isElement(component.el)) return /** @type {Element} */ (component.el);
49
+ throw new TypeError('External field source must be an Element or a Component');
50
+ }
51
+
52
+ /**
53
+ * Reads the current value of an element or component through the first value dialect it
54
+ * supports (see {@link VALUE_METHODS}), then a `value`, `values`, or `checked` property, then the
55
+ * element itself: `checked` for a checkbox or radio, `value` for any other control. Null when
56
+ * the source has no value at all.
57
+ * @param {ExternalSource} source Element or component.
58
+ * @returns {unknown}
59
+ */
60
+ export function readExternalValue(source) {
61
+ return valueAccess(source, externalElement(source)).get();
62
+ }
63
+
64
+ /**
65
+ * Reports whether an element or component is disabled: its `isDisabled()`, a `disabled`
66
+ * property, the element's own `disabled`, an `aria-disabled="true"` or `data-disabled` root, or
67
+ * a wrapper whose every form control is disabled.
68
+ * @param {ExternalSource} source Element or component.
69
+ * @returns {boolean}
70
+ */
71
+ export function readExternalDisabled(source) {
72
+ const el = externalElement(source);
73
+ const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
74
+ if (component && callable(component, 'isDisabled', 0)) return Boolean(component.isDisabled());
75
+ if (component && typeof component.disabled === 'boolean') return component.disabled;
76
+ if (hasNativeProperty(el, 'disabled')) return Boolean(/** @type {any} */ (el).disabled) || el.hasAttribute('disabled');
77
+ if (el.getAttribute('aria-disabled') === 'true') return true;
78
+ const flag = el.getAttribute('data-disabled');
79
+ if (flag !== null && flag !== 'false') return true;
80
+ const controls = [...el.querySelectorAll(CONTROLS)];
81
+ return controls.length > 0 && controls.every((control) => /** @type {any} */ (control).disabled);
82
+ }
83
+
84
+ /**
85
+ * Reports whether an element or component is read-only: a `readonly`/`readOnly` property, the
86
+ * element's own `readOnly`, or an `aria-readonly="true"` or `data-readonly` root.
87
+ * @param {ExternalSource} source Element or component.
88
+ * @returns {boolean}
89
+ */
90
+ export function readExternalReadonly(source) {
91
+ const el = externalElement(source);
92
+ const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
93
+ for (const name of ['readonly', 'readOnly']) {
94
+ if (component && typeof component[name] === 'boolean') return component[name];
95
+ }
96
+ if (hasNativeProperty(el, 'readOnly')) return Boolean(/** @type {any} */ (el).readOnly) || el.hasAttribute('readonly');
97
+ if (el.getAttribute('aria-readonly') === 'true') return true;
98
+ const flag = el.getAttribute('data-readonly');
99
+ return flag !== null && flag !== 'false';
100
+ }
101
+
102
+ /**
103
+ * Builds the Field adapter for an element or component that is not a registered field type.
104
+ *
105
+ * Values go through the source's own accessors ({@link readExternalValue}); disabling through
106
+ * `setDisabled(bool)`, then `enable()`/`disable()`, then a `disabled` property, and only then the
107
+ * DOM — the element's `disabled`, or for a plain wrapper `aria-disabled` plus `disabled` on every
108
+ * form control inside it. Read-only goes through `setReadonly(bool)`/`setReadOnly(bool)`, a
109
+ * `readonly`/`readOnly` property, or the element's own `readOnly`.
110
+ *
111
+ * Writes pass the caller's options to the component: `Field.setValue(v)` or
112
+ * `Fieldset.setValues(values)` without `silent` lets the component emit its own `change` to its
113
+ * own listeners, while the field emits one `change` for the write; with `silent` neither does.
114
+ *
115
+ * Given the field, a component's own `change` event becomes the field's `change`, and the native
116
+ * `change` its inner controls bubble is dropped unless it carries a value the component did not
117
+ * already report — so one edit is one field `change`, whether the component reports it, its
118
+ * input does, or (Search, CopyInput) only its input does.
119
+ *
120
+ * `destroy()` puts the element back where it was found and undoes what the adapter changed:
121
+ * attributes, the native value, disabled and read-only state.
122
+ * @param {ExternalSource} source Element or component to adopt.
123
+ * @param {Field|null} [field=null] The field the adapter serves; needed to forward events. Its
124
+ * `listen()` owns every listener the adapter adds, so destroying the field removes them.
125
+ * @returns {FieldAdapter}
126
+ */
127
+ export function externalAdapter(source, field = null) {
128
+ const el = externalElement(source);
129
+ const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
130
+ const parent = el.parentNode;
131
+ const next = el.nextSibling;
132
+ const attributes = new Map(['id', 'aria-labelledby', 'aria-describedby', 'aria-disabled', 'aria-invalid']
133
+ .map((name) => [name, el.getAttribute(name)]));
134
+ const native = component === null;
135
+ const originalValue = native && hasNativeProperty(el, 'value') ? /** @type {any} */ (el).value : undefined;
136
+ const originalChecked = native && isCheckable(el) ? /** @type {any} */ (el).checked : undefined;
137
+ const originalDisabled = readExternalDisabled(source);
138
+ const originalReadonly = readExternalReadonly(source);
139
+ /** @type {Map<Element, {disabled?: boolean, readOnly?: boolean}>} */
140
+ const poked = new Map();
141
+ const value = valueAccess(source, el);
142
+ const disabled = disabledAccess(component, el, poked);
143
+ const readonly = readonlyAccess(component, el, poked);
144
+ let lastDisabled = originalDisabled;
145
+ let lastReadonly = originalReadonly;
146
+ let setting = false;
147
+ let reported = value.get();
148
+
149
+ if (field && component && typeof component.addEventListener === 'function') {
150
+ // Through the field's `listen()`, so destroying the field unsubscribes. A write through
151
+ // `set()` is not forwarded: the Field reports that one itself.
152
+ field.listen(/** @type {any} */ (component), 'change', () => {
153
+ if (setting) return;
154
+ reported = value.get();
155
+ field.emit('change', { value: reported });
156
+ });
157
+ // Registered before the Field's own `change` listener on the same element, so a native
158
+ // change the component already reported never reaches it.
159
+ field.listen(el, 'change', (event) => {
160
+ const current = value.get();
161
+ if (sameValue(current, reported)) event.stopImmediatePropagation();
162
+ else reported = current;
163
+ });
164
+ }
165
+
166
+ /** @type {FieldAdapter} */
167
+ const adapter = {
168
+ el,
169
+ get: () => value.get(),
170
+ set: (next, options) => {
171
+ setting = true;
172
+ try { value.set(next, options); }
173
+ finally { setting = false; }
174
+ reported = value.get();
175
+ },
176
+ focus: () => {
177
+ if (component && callable(component, 'focus', 0)) component.focus();
178
+ else if (typeof (/** @type {any} */ (el).focus) === 'function') /** @type {any} */ (el).focus();
179
+ },
180
+ setDisabled: (state) => {
181
+ lastDisabled = Boolean(state);
182
+ disabled(lastDisabled);
183
+ },
184
+ destroy: () => {
185
+ if (lastReadonly !== originalReadonly && readonly) readonly(originalReadonly);
186
+ if (lastDisabled !== originalDisabled) disabled(originalDisabled);
187
+ for (const [control, state] of poked) {
188
+ if (state.disabled !== undefined) /** @type {any} */ (control).disabled = state.disabled;
189
+ if (state.readOnly !== undefined) /** @type {any} */ (control).readOnly = state.readOnly;
190
+ }
191
+ poked.clear();
192
+ for (const [name, saved] of attributes) {
193
+ if (saved === null) el.removeAttribute(name);
194
+ else el.setAttribute(name, saved);
195
+ }
196
+ if (originalValue !== undefined) /** @type {any} */ (el).value = originalValue;
197
+ if (originalChecked !== undefined) /** @type {any} */ (el).checked = originalChecked;
198
+ if (parent) parent.insertBefore(el, next?.parentNode === parent ? next : null);
199
+ else el.remove();
200
+ }
201
+ };
202
+ if (readonly) {
203
+ adapter.setReadonly = (state) => {
204
+ lastReadonly = Boolean(state);
205
+ readonly(lastReadonly);
206
+ };
207
+ }
208
+ return adapter;
209
+ }
210
+
211
+ /**
212
+ * The value reader and writer for a source: the first {@link VALUE_METHODS} pair whose reader
213
+ * exists (its own writer first, then any other dialect's), then a value property, then the
214
+ * element.
215
+ * @param {ExternalSource} source Element or component.
216
+ * @param {Element} el Its element.
217
+ * @returns {{get: () => unknown, set: (value: unknown, options?: {silent?: boolean}) => void}}
218
+ */
219
+ function valueAccess(source, el) {
220
+ const component = isElement(source) ? null : /** @type {Record<string, any>} */ (source);
221
+ if (component) {
222
+ const pair = VALUE_METHODS.find(([reader]) => callable(component, reader, 0));
223
+ // The pair's own writer whatever its signature; another dialect's only when the value is its
224
+ // sole required argument.
225
+ const writer = !pair ? undefined : callable(component, pair[1], 2)
226
+ ? pair[1]
227
+ : VALUE_METHODS.map(([, name]) => name).find((name) => callable(component, name, 1));
228
+ if (pair) {
229
+ return {
230
+ get: () => component[pair[0]](),
231
+ // The caller's options pass through: a write that is not silent reaches the component's
232
+ // own `change` listeners too, and the adapter does not forward it a second time.
233
+ set: (next, options = {}) => {
234
+ if (writer) component[writer](next, { ...options, silent: Boolean(options.silent) });
235
+ }
236
+ };
237
+ }
238
+ const property = VALUE_PROPERTIES.find((name) => name in component);
239
+ if (property) {
240
+ return {
241
+ get: () => component[property],
242
+ set: (next) => { component[property] = next; }
243
+ };
244
+ }
245
+ }
246
+ if (isCheckable(el)) {
247
+ return {
248
+ get: () => Boolean(/** @type {any} */ (el).checked),
249
+ set: (next) => { /** @type {any} */ (el).checked = Boolean(next); }
250
+ };
251
+ }
252
+ if (hasNativeProperty(el, 'value')) {
253
+ return {
254
+ get: () => /** @type {any} */ (el).value,
255
+ set: (next) => { /** @type {any} */ (el).value = next == null ? '' : next; }
256
+ };
257
+ }
258
+ return { get: () => null, set: () => {} };
259
+ }
260
+
261
+ /**
262
+ * The disabling dialect for a source.
263
+ * @param {Record<string, any>|null} component Component, or null for a plain element.
264
+ * @param {Element} el Its element.
265
+ * @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked Original states of the
266
+ * controls the DOM fallback touched.
267
+ * @returns {(disabled: boolean) => void}
268
+ */
269
+ function disabledAccess(component, el, poked) {
270
+ if (component && callable(component, 'setDisabled', 1)) return (state) => component.setDisabled(state);
271
+ if (component && callable(component, 'enable', 0) && callable(component, 'disable', 0)) {
272
+ return (state) => (state ? component.disable() : component.enable());
273
+ }
274
+ if (component && typeof component.disabled === 'boolean') return (state) => { component.disabled = state; };
275
+ if (hasNativeProperty(el, 'disabled')) return (state) => { /** @type {any} */ (el).disabled = state; };
276
+ return (state) => {
277
+ el.setAttribute('aria-disabled', String(state));
278
+ for (const control of el.querySelectorAll(CONTROLS)) {
279
+ remember(poked, control, 'disabled');
280
+ /** @type {any} */ (control).disabled = state || Boolean(poked.get(control)?.disabled);
281
+ }
282
+ };
283
+ }
284
+
285
+ /**
286
+ * The read-only dialect for a source, or null when it has none.
287
+ * @param {Record<string, any>|null} component Component, or null for a plain element.
288
+ * @param {Element} el Its element.
289
+ * @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked Original states of the
290
+ * controls the DOM fallback touched.
291
+ * @returns {((readonly: boolean) => void)|null}
292
+ */
293
+ function readonlyAccess(component, el, poked) {
294
+ if (component) {
295
+ for (const name of ['setReadonly', 'setReadOnly']) {
296
+ if (callable(component, name, 1)) return (state) => component[name](state);
297
+ }
298
+ for (const name of ['readonly', 'readOnly']) {
299
+ if (typeof component[name] === 'boolean') return (state) => { component[name] = state; };
300
+ }
301
+ return null;
302
+ }
303
+ if (hasNativeProperty(el, 'readOnly')) return (state) => { /** @type {any} */ (el).readOnly = state; };
304
+ const controls = [...el.querySelectorAll(TEXT_CONTROLS)];
305
+ if (!controls.length) return null;
306
+ return (state) => {
307
+ for (const control of controls) {
308
+ remember(poked, control, 'readOnly');
309
+ /** @type {any} */ (control).readOnly = state || Boolean(poked.get(control)?.readOnly);
310
+ }
311
+ };
312
+ }
313
+
314
+ /**
315
+ * @param {Map<Element, {disabled?: boolean, readOnly?: boolean}>} poked
316
+ * @param {Element} control
317
+ * @param {'disabled'|'readOnly'} name
318
+ * @returns {void}
319
+ */
320
+ function remember(poked, control, name) {
321
+ const state = poked.get(control) ?? {};
322
+ if (state[name] === undefined) state[name] = Boolean(/** @type {any} */ (control)[name]);
323
+ poked.set(control, state);
324
+ }
325
+
326
+ /**
327
+ * Whether `target[name]` is a method taking at most `arity` required arguments.
328
+ * @param {Record<string, any>} target
329
+ * @param {string} name
330
+ * @param {number} arity
331
+ * @returns {boolean}
332
+ */
333
+ function callable(target, name, arity) {
334
+ const fn = target[name];
335
+ return typeof fn === 'function' && fn.length <= arity;
336
+ }
337
+
338
+ /** @param {Element} el @param {string} name @returns {boolean} */
339
+ function hasNativeProperty(el, name) {
340
+ return ['INPUT', 'SELECT', 'TEXTAREA', 'BUTTON', 'FIELDSET', 'OUTPUT'].includes(el.tagName) && name in el;
341
+ }
342
+
343
+ /** @param {Element} el @returns {boolean} */
344
+ function isCheckable(el) {
345
+ const type = String(/** @type {any} */ (el).type ?? '').toLowerCase();
346
+ return el.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio');
347
+ }
348
+
349
+ /**
350
+ * Value equality for change de-duplication: identical primitives, Dates at the same instant,
351
+ * arrays and plain objects with the same JSON.
352
+ * @param {unknown} a
353
+ * @param {unknown} b
354
+ * @returns {boolean}
355
+ */
356
+ function sameValue(a, b) {
357
+ if (Object.is(a, b)) return true;
358
+ if (a instanceof Date && b instanceof Date) return a.getTime() === b.getTime();
359
+ if (a && b && typeof a === 'object' && typeof b === 'object') {
360
+ try { return JSON.stringify(a) === JSON.stringify(b); } catch { return false; }
361
+ }
362
+ return false;
363
+ }