@es-joy/jsoe 0.28.2 → 0.30.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 (167) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGES.md +25 -0
  3. package/README.md +4 -0
  4. package/dist/formatAndTypeChoices.d.ts +6 -6
  5. package/dist/formatAndTypeChoices.d.ts.map +1 -1
  6. package/dist/formats/schema.d.ts +5 -0
  7. package/dist/formats/schema.d.ts.map +1 -1
  8. package/dist/formats.d.ts +2 -2
  9. package/dist/formats.d.ts.map +1 -1
  10. package/dist/fundamentalTypes/dateType.d.ts +26 -2
  11. package/dist/fundamentalTypes/dateType.d.ts.map +1 -1
  12. package/dist/fundamentalTypes/domexceptionType.d.ts +7 -0
  13. package/dist/fundamentalTypes/domexceptionType.d.ts.map +1 -1
  14. package/dist/index.d.ts +2 -0
  15. package/dist/index.d.ts.map +1 -1
  16. package/dist/index.js +2 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/jtltExtensions.d.ts +112 -0
  19. package/dist/jtltExtensions.d.ts.map +1 -0
  20. package/dist/search/fundamentalTypes/arraySearchType.d.ts +22 -0
  21. package/dist/search/fundamentalTypes/arraySearchType.d.ts.map +1 -0
  22. package/dist/search/fundamentalTypes/bigintSearchType.d.ts +10 -0
  23. package/dist/search/fundamentalTypes/bigintSearchType.d.ts.map +1 -0
  24. package/dist/search/fundamentalTypes/blobSearchType.d.ts +19 -0
  25. package/dist/search/fundamentalTypes/blobSearchType.d.ts.map +1 -0
  26. package/dist/search/fundamentalTypes/booleanSearchType.d.ts +11 -0
  27. package/dist/search/fundamentalTypes/booleanSearchType.d.ts.map +1 -0
  28. package/dist/search/fundamentalTypes/catchSearchType.d.ts +18 -0
  29. package/dist/search/fundamentalTypes/catchSearchType.d.ts.map +1 -0
  30. package/dist/search/fundamentalTypes/dateSearchType.d.ts +15 -0
  31. package/dist/search/fundamentalTypes/dateSearchType.d.ts.map +1 -0
  32. package/dist/search/fundamentalTypes/domexceptionSearchType.d.ts +16 -0
  33. package/dist/search/fundamentalTypes/domexceptionSearchType.d.ts.map +1 -0
  34. package/dist/search/fundamentalTypes/enumSearchType.d.ts +26 -0
  35. package/dist/search/fundamentalTypes/enumSearchType.d.ts.map +1 -0
  36. package/dist/search/fundamentalTypes/errorSearchType.d.ts +9 -0
  37. package/dist/search/fundamentalTypes/errorSearchType.d.ts.map +1 -0
  38. package/dist/search/fundamentalTypes/fileSearchType.d.ts +28 -0
  39. package/dist/search/fundamentalTypes/fileSearchType.d.ts.map +1 -0
  40. package/dist/search/fundamentalTypes/filelistSearchType.d.ts +24 -0
  41. package/dist/search/fundamentalTypes/filelistSearchType.d.ts.map +1 -0
  42. package/dist/search/fundamentalTypes/functionSearchType.d.ts +18 -0
  43. package/dist/search/fundamentalTypes/functionSearchType.d.ts.map +1 -0
  44. package/dist/search/fundamentalTypes/mapSearchType.d.ts +22 -0
  45. package/dist/search/fundamentalTypes/mapSearchType.d.ts.map +1 -0
  46. package/dist/search/fundamentalTypes/nanSearchType.d.ts +7 -0
  47. package/dist/search/fundamentalTypes/nanSearchType.d.ts.map +1 -0
  48. package/dist/search/fundamentalTypes/nullSearchType.d.ts +7 -0
  49. package/dist/search/fundamentalTypes/nullSearchType.d.ts.map +1 -0
  50. package/dist/search/fundamentalTypes/numberSearchType.d.ts +11 -0
  51. package/dist/search/fundamentalTypes/numberSearchType.d.ts.map +1 -0
  52. package/dist/search/fundamentalTypes/objectSearchType.d.ts +21 -0
  53. package/dist/search/fundamentalTypes/objectSearchType.d.ts.map +1 -0
  54. package/dist/search/fundamentalTypes/promiseSearchType.d.ts +20 -0
  55. package/dist/search/fundamentalTypes/promiseSearchType.d.ts.map +1 -0
  56. package/dist/search/fundamentalTypes/regexpSearchType.d.ts +20 -0
  57. package/dist/search/fundamentalTypes/regexpSearchType.d.ts.map +1 -0
  58. package/dist/search/fundamentalTypes/setSearchType.d.ts +15 -0
  59. package/dist/search/fundamentalTypes/setSearchType.d.ts.map +1 -0
  60. package/dist/search/fundamentalTypes/stringSearchType.d.ts +21 -0
  61. package/dist/search/fundamentalTypes/stringSearchType.d.ts.map +1 -0
  62. package/dist/search/fundamentalTypes/symbolSearchType.d.ts +16 -0
  63. package/dist/search/fundamentalTypes/symbolSearchType.d.ts.map +1 -0
  64. package/dist/search/fundamentalTypes/undefinedSearchType.d.ts +7 -0
  65. package/dist/search/fundamentalTypes/undefinedSearchType.d.ts.map +1 -0
  66. package/dist/search/index.d.ts +70 -0
  67. package/dist/search/index.d.ts.map +1 -0
  68. package/dist/search/queryTree.d.ts +12 -1
  69. package/dist/search/queryTree.d.ts.map +1 -1
  70. package/dist/search/queryTreeBuilders.d.ts +18 -0
  71. package/dist/search/queryTreeBuilders.d.ts.map +1 -1
  72. package/dist/search/searchDispatch.d.ts +44 -0
  73. package/dist/search/searchDispatch.d.ts.map +1 -1
  74. package/dist/search/searchElementUtils.d.ts +106 -0
  75. package/dist/search/searchElementUtils.d.ts.map +1 -0
  76. package/dist/search/searchUtils.d.ts +550 -0
  77. package/dist/search/searchUtils.d.ts.map +1 -0
  78. package/dist/search/subTypes/blobHTMLSearchType.d.ts +36 -0
  79. package/dist/search/subTypes/blobHTMLSearchType.d.ts.map +1 -0
  80. package/dist/search/subTypes/recordSearchType.d.ts +18 -0
  81. package/dist/search/subTypes/recordSearchType.d.ts.map +1 -0
  82. package/dist/search/subTypes/tupleSearchType.d.ts +21 -0
  83. package/dist/search/subTypes/tupleSearchType.d.ts.map +1 -0
  84. package/dist/search/superTypes/SpecialRealNumberSearchType.d.ts +14 -0
  85. package/dist/search/superTypes/SpecialRealNumberSearchType.d.ts.map +1 -0
  86. package/dist/search/superTypes/buffersourceSearchType.d.ts +17 -0
  87. package/dist/search/superTypes/buffersourceSearchType.d.ts.map +1 -0
  88. package/dist/search/superTypes/dommatrixSearchType.d.ts +11 -0
  89. package/dist/search/superTypes/dommatrixSearchType.d.ts.map +1 -0
  90. package/dist/search/superTypes/dompointSearchType.d.ts +9 -0
  91. package/dist/search/superTypes/dompointSearchType.d.ts.map +1 -0
  92. package/dist/search/superTypes/domrectSearchType.d.ts +9 -0
  93. package/dist/search/superTypes/domrectSearchType.d.ts.map +1 -0
  94. package/dist/search/superTypes/errorsSpecialSearchType.d.ts +11 -0
  95. package/dist/search/superTypes/errorsSpecialSearchType.d.ts.map +1 -0
  96. package/dist/search/unions/discriminatedUnionSearchType.d.ts +12 -0
  97. package/dist/search/unions/discriminatedUnionSearchType.d.ts.map +1 -0
  98. package/dist/search/unions/unionFamilySearchType.d.ts +36 -0
  99. package/dist/search/unions/unionFamilySearchType.d.ts.map +1 -0
  100. package/dist/search/unions/unionSearchType.d.ts +8 -0
  101. package/dist/search/unions/unionSearchType.d.ts.map +1 -0
  102. package/dist/search/unions/xorSearchType.d.ts +12 -0
  103. package/dist/search/unions/xorSearchType.d.ts.map +1 -0
  104. package/dist/superTypes/buffersourceType.d.ts.map +1 -1
  105. package/dist/typeChoices.d.ts +3 -3
  106. package/dist/typeChoices.d.ts.map +1 -1
  107. package/dist/types.d.ts +3 -3
  108. package/dist/types.d.ts.map +1 -1
  109. package/package.json +5 -4
  110. package/pnpm-workspace.yaml +1 -1
  111. package/rollup.config.js +12 -0
  112. package/src/formatAndTypeChoices.js +4 -4
  113. package/src/formats/schema.js +1 -1
  114. package/src/formats.js +1 -1
  115. package/src/fundamentalTypes/dateType.js +40 -14
  116. package/src/fundamentalTypes/domexceptionType.js +41 -33
  117. package/src/fundamentalTypes/regexpType.js +1 -1
  118. package/src/index.js +6 -0
  119. package/src/jsoe.css +204 -0
  120. package/src/jtltExtensions.js +103 -0
  121. package/src/search/fundamentalTypes/arraySearchType.js +101 -0
  122. package/src/search/fundamentalTypes/bigintSearchType.js +68 -0
  123. package/src/search/fundamentalTypes/blobSearchType.js +42 -0
  124. package/src/search/fundamentalTypes/booleanSearchType.js +40 -0
  125. package/src/search/fundamentalTypes/catchSearchType.js +59 -0
  126. package/src/search/fundamentalTypes/dateSearchType.js +166 -0
  127. package/src/search/fundamentalTypes/domexceptionSearchType.js +84 -0
  128. package/src/search/fundamentalTypes/enumSearchType.js +62 -0
  129. package/src/search/fundamentalTypes/errorSearchType.js +11 -0
  130. package/src/search/fundamentalTypes/fileSearchType.js +94 -0
  131. package/src/search/fundamentalTypes/filelistSearchType.js +103 -0
  132. package/src/search/fundamentalTypes/functionSearchType.js +102 -0
  133. package/src/search/fundamentalTypes/mapSearchType.js +117 -0
  134. package/src/search/fundamentalTypes/nanSearchType.js +11 -0
  135. package/src/search/fundamentalTypes/nullSearchType.js +11 -0
  136. package/src/search/fundamentalTypes/numberSearchType.js +59 -0
  137. package/src/search/fundamentalTypes/objectSearchType.js +367 -0
  138. package/src/search/fundamentalTypes/promiseSearchType.js +61 -0
  139. package/src/search/fundamentalTypes/regexpSearchType.js +72 -0
  140. package/src/search/fundamentalTypes/setSearchType.js +91 -0
  141. package/src/search/fundamentalTypes/stringSearchType.js +44 -0
  142. package/src/search/fundamentalTypes/symbolSearchType.js +39 -0
  143. package/src/search/fundamentalTypes/undefinedSearchType.js +11 -0
  144. package/src/search/index.js +96 -0
  145. package/src/search/queryTree.js +244 -0
  146. package/src/search/queryTreeBuilders.js +233 -0
  147. package/src/search/searchDispatch.js +291 -0
  148. package/src/search/searchElementUtils.js +373 -0
  149. package/src/search/searchUtils.js +847 -0
  150. package/src/search/subTypes/blobHTMLSearchType.js +122 -0
  151. package/src/search/subTypes/recordSearchType.js +105 -0
  152. package/src/search/subTypes/tupleSearchType.js +152 -0
  153. package/src/search/superTypes/SpecialRealNumberSearchType.js +42 -0
  154. package/src/search/superTypes/buffersourceSearchType.js +53 -0
  155. package/src/search/superTypes/dommatrixSearchType.js +24 -0
  156. package/src/search/superTypes/dompointSearchType.js +15 -0
  157. package/src/search/superTypes/domrectSearchType.js +15 -0
  158. package/src/search/superTypes/errorsSpecialSearchType.js +15 -0
  159. package/src/search/unions/discriminatedUnionSearchType.js +16 -0
  160. package/src/search/unions/unionFamilySearchType.js +168 -0
  161. package/src/search/unions/unionSearchType.js +12 -0
  162. package/src/search/unions/xorSearchType.js +16 -0
  163. package/src/superTypes/buffersourceType.js +13 -15
  164. package/src/typeChoices.js +2 -2
  165. package/src/types.js +2 -2
  166. package/tsconfig-prod.json +12 -0
  167. package/tsconfig.json +5 -10
@@ -0,0 +1,847 @@
1
+ import {schemaLabel} from '../utils/schemaMeta.js';
2
+ import {getJSONPointerParts} from '../utils/jsonPointer.js';
3
+
4
+ /**
5
+ * @typedef {import('../types.js').JamilihArray} JamilihArray
6
+ */
7
+
8
+ /**
9
+ * Label for a leaf/container's own control, preferring the schema's own
10
+ * `meta.title`/`meta.description`/`description` (via `schemaLabel`, same
11
+ * precedence the value-editing side uses) and falling back to the JSON
12
+ * Pointer path's final segment so every control still has a legible label
13
+ * even when the schema carries no metadata.
14
+ * @param {import('../formats/schema.js').ZodexSchema|undefined} schemaObject
15
+ * @param {string} path
16
+ * @returns {string}
17
+ */
18
+ export function buildPathLabel (schemaObject, path) {
19
+ const label = schemaLabel(schemaObject);
20
+ if (label) {
21
+ return label;
22
+ }
23
+ const parts = getJSONPointerParts(path);
24
+ return parts.length ? String(parts.at(-1)) : path;
25
+ }
26
+
27
+ /**
28
+ * Finds a control belonging directly to `root`'s own widget - as opposed to
29
+ * one belonging to a search widget recursively nested inside it (e.g.
30
+ * `arraySearchType.js`'s own "size" input vs. a nested array-of-arrays'
31
+ * element widget's own "size" input, both reachable from `root` via a plain
32
+ * `querySelectorAll`). A match only counts if the nearest ancestor of the
33
+ * match carrying `data-search-path` is `root` itself; every search element
34
+ * carries that attribute (`searchElementUtils.js`'s `findSearchElement`), so
35
+ * this reliably stops at the first nested search element's boundary.
36
+ *
37
+ * This is also why every `build*`/`read*` pair below reads its class-based
38
+ * selector back through this helper rather than a raw
39
+ * `root.querySelector(...)`: a fixed class name is safe to reuse across
40
+ * every instance of a given control on the page precisely because lookups
41
+ * are always scoped this way, and - unlike a `name` built from a
42
+ * `typeNamespace` closed over inside a `$define` mixin, which is installed
43
+ * once on the shared custom-element prototype the first time a tag is
44
+ * defined - nothing here depends on a per-instance closure at all.
45
+ * @param {Element} root
46
+ * @param {string} selector
47
+ * @returns {HTMLElement|undefined}
48
+ */
49
+ export function findOwnControl (root, selector) {
50
+ return /** @type {HTMLElement[]} */ (
51
+ [...root.querySelectorAll(selector)]
52
+ ).find((el) => el.closest('[data-search-path]') === root);
53
+ }
54
+
55
+ /**
56
+ * Whether `el` sits inside an `objectSearchType.js` has-property row whose
57
+ * own "Has"/"Doesn't have" tri-state is *already* explicitly chosen (not
58
+ * "(any)") - if so, that existence assertion alone is already a complete
59
+ * constraint for the row, so a per-field "no absent values" requirement
60
+ * inside it (a range's own "at least one bound", `buildRangeInputsPair`'s
61
+ * doc) should be relaxed. `setDescendantsRequired` handles this for plain
62
+ * `required`-attribute controls, but a range pair's requirement is
63
+ * re-asserted imperatively on every `input`/`change` via `setCustomValidity`
64
+ * (`syncRangeValidity`, `dateSearchType.js`'s own equivalent) - clearing it
65
+ * once wouldn't stick past the next keystroke, so those call this directly,
66
+ * live, instead.
67
+ *
68
+ * Returns `false` (not exempted) when `el` also sits inside a
69
+ * `buildOptInFieldset` fieldset (`jsoeSearchOptInFieldset--*`), for the same
70
+ * reason `setDescendantsRequired` skips those controls: a dimension range
71
+ * nested in `makeDomShapeSearchType`'s per-dimension opt-in gate (say,
72
+ * DOMRect's "Width") is only reachable at all once its own checkbox is
73
+ * checked, and checking it is itself the user's explicit request to
74
+ * constrain *that* facet - an enclosing "Has property" shouldn't then let
75
+ * its "From"/"To" sit blank and still read as valid, any more than
76
+ * `fileSearchType.js`'s "Name" facet should once opted into.
77
+ * @param {Element} el
78
+ * @returns {boolean}
79
+ */
80
+ export function isExemptedByAncestorHasProperty (el) {
81
+ if (el.closest('fieldset[class^="jsoeSearchOptInFieldset--"]')) {
82
+ return false;
83
+ }
84
+ const row = el.closest('jsoe-search-has-property');
85
+ if (!row) {
86
+ return false;
87
+ }
88
+ const select = /** @type {HTMLSelectElement|undefined} */ (
89
+ findOwnControl(row, 'select.jsoeSearchTriState--')
90
+ );
91
+ return select !== undefined && select.value !== '';
92
+ }
93
+
94
+ /**
95
+ * Re-runs every `input`/`change`-driven validator within `root` by
96
+ * re-dispatching those events on each descendant form control -
97
+ * `objectSearchType.js`'s `buildHasPropertyRow` calls this when its own
98
+ * "Has"/"Doesn't have" tri-state changes, since a range pair (or anything
99
+ * else reactive) nested anywhere inside the child widget needs to
100
+ * re-evaluate `isExemptedByAncestorHasProperty` against the *new* tri-state
101
+ * value - nothing else would otherwise prompt it to run again until the
102
+ * user happens to interact with that specific field themselves.
103
+ * @param {Element} root
104
+ * @returns {void}
105
+ */
106
+ export function revalidateDescendants (root) {
107
+ [...root.querySelectorAll('input, select, textarea')].forEach((el) => {
108
+ el.dispatchEvent(new Event('input'));
109
+ el.dispatchEvent(new Event('change'));
110
+ });
111
+ }
112
+
113
+ /**
114
+ * The `buildRangeInputsPair` cross-validation check, factored out so a
115
+ * consuming widget's own `$define.connectedCallback` (custom elements,
116
+ * search plan §8) can also call it - the `input`/`change` events
117
+ * `buildRangeInputsPair` wires below only fire from the user's *first*
118
+ * interaction, so a freshly-built pair with both ends still empty would
119
+ * otherwise stay "valid" by the browser's reckoning (no `setCustomValidity`
120
+ * call has ever run yet) until then, contradicting the "leaving both blank
121
+ * is invalid" rule documented below. `connectedCallback` is a native
122
+ * Custom Elements lifecycle method (invoked once per instance the moment it
123
+ * connects to the document), so calling this from there closes that gap
124
+ * without needing any build-time live element reference.
125
+ *
126
+ * "Leaving both blank is invalid" is itself relaxed when
127
+ * `isExemptedByAncestorHasProperty` says so - a range nested as a has-
128
+ * property row's own child widget (e.g. a `date`/`number` property) needs
129
+ * the same "'Has property X' is already a complete constraint" relief
130
+ * `setDescendantsRequired` gives `required`-attribute controls, just
131
+ * re-checked live on every call rather than toggled once, since this runs
132
+ * imperatively on every keystroke regardless.
133
+ * @param {Element} root
134
+ * @param {string} [key]
135
+ * @returns {void}
136
+ */
137
+ export function syncRangeValidity (root, key = '') {
138
+ const gteEl = /** @type {HTMLInputElement|undefined} */ (
139
+ findOwnControl(root, `input.jsoeSearchRangeGte--${key}`)
140
+ );
141
+ const lteEl = /** @type {HTMLInputElement|undefined} */ (
142
+ findOwnControl(root, `input.jsoeSearchRangeLte--${key}`)
143
+ );
144
+ if (!gteEl || !lteEl) {
145
+ return;
146
+ }
147
+ const bothEmpty = gteEl.value === '' && lteEl.value === '' &&
148
+ !isExemptedByAncestorHasProperty(gteEl);
149
+ const outOfOrder = gteEl.value !== '' && lteEl.value !== '' &&
150
+ Number(lteEl.value) < Number(gteEl.value);
151
+ const emptyMessage = 'Enter at least one bound (From or To).';
152
+ gteEl.setCustomValidity(bothEmpty ? emptyMessage : '');
153
+ let lteMessage = '';
154
+ if (bothEmpty) {
155
+ lteMessage = emptyMessage;
156
+ } else if (outOfOrder) {
157
+ lteMessage = 'End of range must not be less than the start of the range.';
158
+ }
159
+ lteEl.setCustomValidity(lteMessage);
160
+ }
161
+
162
+ /**
163
+ * A "from"/"to" pair of native inputs sharing one `type`, for the OR-range/
164
+ * Is-Not-Range README affordance (number/bigint/buffersource; `date` uses
165
+ * `dateType.js`'s own `buildDateInputControl` instead, since a
166
+ * `datetime-local` input needs its own ISO-slicing).
167
+ *
168
+ * `key` distinguishes multiple range pairs *within one widget* (e.g.
169
+ * `domrectSearchType.js`'s `x`/`y`/`width`/`height` dimensions, or
170
+ * `errorSearchType.js`'s `lineNumber`/`columnNumber`): `findOwnControl`
171
+ * already isolates one widget's controls from another's, but can't tell
172
+ * apart two same-class controls belonging to the *same* widget, so those
173
+ * need distinct classes. Leave it at the default `''` for the common case
174
+ * of a widget with only one range pair.
175
+ *
176
+ * Cross-validates the pair via the native Constraint Validation API,
177
+ * surfaced by `jsoe.css`'s `input:invalid` styling and a real `<form>`'s
178
+ * `reportValidity`/`checkValidity` (`src/search/index.js`): leaving *both*
179
+ * ends blank is invalid - the same "no absent values" reasoning
180
+ * `buildLiteralRegexControls`'s Value input documents, an added range row
181
+ * needs at least one bound to mean anything - and if both ends are filled
182
+ * with "To" less than "From", that's invalid too. An open-ended range (only
183
+ * one end filled) stays valid. A row nested under an
184
+ * `objectSearchType.js` required-property's opt-in `<fieldset disabled>`
185
+ * (or, for `dateSearchType.js`'s own equivalent pair, its own
186
+ * "Invalid date" fieldset) is unaffected either way, since a disabled field
187
+ * is excluded from constraint validation entirely. Every caller must also
188
+ * call `syncRangeValidity` from its own `connectedCallback` (see that
189
+ * function's doc) so the initial both-blank state is actually invalid from
190
+ * the moment the widget exists, not just after the user's first keystroke.
191
+ * `dateSearchType.js`'s own `datetime-local` pair needs the same two checks
192
+ * but isn't built through this helper (`buildDateInputControl` handles its
193
+ * own ISO-slicing), so it wires an equivalent handler itself.
194
+ * @param {{
195
+ * name: string,
196
+ * key?: string,
197
+ * type?: string,
198
+ * min?: string|number,
199
+ * max?: string|number,
200
+ * step?: string|number
201
+ * }} cfg
202
+ * @returns {JamilihArray[]}
203
+ */
204
+ export function buildRangeInputsPair ({
205
+ name, key = '', type = 'number', min, max, step
206
+ }) {
207
+ /**
208
+ * `this` is whichever of the pair fired the event - `syncRangeValidity`
209
+ * looks up both fresh via `findOwnControl` rather than closing over
210
+ * either, so it stays correct however many `buildRangeInputsPair` pairs
211
+ * (of possibly-differing `key`s) end up sharing the same widget.
212
+ * @this {HTMLElement}
213
+ * @returns {void}
214
+ */
215
+ function validateRange () {
216
+ const root = this.closest('[data-search-path]');
217
+ if (root) {
218
+ syncRangeValidity(root, key);
219
+ }
220
+ }
221
+ return [
222
+ ['label', [
223
+ 'From: ',
224
+ ['input', {
225
+ name: `${name}-gte`, class: `jsoeSearchRangeGte--${key}`, type, min, max, step,
226
+ $on: {input: validateRange, change: validateRange}
227
+ }]
228
+ ]],
229
+ ['label', [
230
+ 'To: ',
231
+ ['input', {
232
+ name: `${name}-lte`, class: `jsoeSearchRangeLte--${key}`, type, min, max, step,
233
+ $on: {input: validateRange, change: validateRange}
234
+ }]
235
+ ]]
236
+ ];
237
+ }
238
+
239
+ /**
240
+ * Reads back a `buildRangeInputsPair` - pass the same `key` it was built
241
+ * with.
242
+ * @param {HTMLElement} el
243
+ * @param {string} [key]
244
+ * @returns {{gte: string, lte: string}}
245
+ */
246
+ export function readRangeInputsPair (el, key = '') {
247
+ const gte = /** @type {HTMLInputElement|undefined} */ (
248
+ findOwnControl(el, `input.jsoeSearchRangeGte--${key}`)
249
+ )?.value ?? '';
250
+ const lte = /** @type {HTMLInputElement|undefined} */ (
251
+ findOwnControl(el, `input.jsoeSearchRangeLte--${key}`)
252
+ )?.value ?? '';
253
+ return {gte, lte};
254
+ }
255
+
256
+ /**
257
+ * A `<select multiple>` populated from a fixed candidate list - used by
258
+ * `enum`/`multiSelect`/`literalSet` leaf controls (README: "multiple
259
+ * select").
260
+ *
261
+ * `required`, when set, makes leaving *every* option unselected invalid -
262
+ * `<select multiple required>` is natively satisfied by one or more
263
+ * selections, no custom validity code needed. `enumSearchType.js` passes
264
+ * this (an enum widget has nothing else to constrain, so an empty selection
265
+ * is never a meaningful "no constraint" state - same "no absent values"
266
+ * reasoning as `buildLiteralRegexControls`'s Value input); other callers
267
+ * (`regexpSearchType.js`'s Flags, `SpecialRealNumberSearchType.js`) leave it
268
+ * at the default `false`, since an empty selection there legitimately means
269
+ * "no constraint on this facet" alongside a widget that has other facets.
270
+ * @param {{
271
+ * name: string,
272
+ * options: (string|[value: string, title: string])[],
273
+ * required?: boolean
274
+ * }} cfg
275
+ * @returns {JamilihArray}
276
+ */
277
+ export function buildMultiSelect ({name, options, required = false}) {
278
+ return ['select', {
279
+ name, multiple: true, required, class: 'jsoeSearchMultiSelect'
280
+ }, options.map((opt) => {
281
+ const [value, title] = Array.isArray(opt) ? opt : [opt, opt];
282
+ return ['option', {value}, [title]];
283
+ })];
284
+ }
285
+
286
+ /**
287
+ * Reads back a `buildMultiSelect` into the selected values, `[]` if none.
288
+ * @param {Element} el
289
+ * @returns {string[]}
290
+ */
291
+ export function readMultiSelect (el) {
292
+ const select = /** @type {HTMLSelectElement|undefined} */ (
293
+ findOwnControl(el, 'select.jsoeSearchMultiSelect')
294
+ );
295
+ return [...(select?.selectedOptions ?? [])].map((opt) => opt.value);
296
+ }
297
+
298
+ /**
299
+ * A tri-state "has property" control for one already-chosen property name
300
+ * (README: "Has property &lt;property pull-down&gt;", "avoid listing
301
+ * required"). `objectSearchType.js` supplies the additive pull-down that
302
+ * lets a user pick *which* property to add one of these for; this helper is
303
+ * only the fixed per-property toggle it adds each time, so the "any" state
304
+ * (no constraint entered) is distinguishable from an explicit "has"/
305
+ * "doesn't have" - matching `SearchTypeObject.getQuery`'s
306
+ * `undefined`-means-"no constraint" convention. Shares `readTriStateSelect`
307
+ * with `buildTriStateSelect` below - same three-value shape, just different
308
+ * option labels.
309
+ * @param {{name: string, propertyName: string}} cfg
310
+ * @returns {JamilihArray}
311
+ */
312
+ export function buildHasPropertyToggle ({name, propertyName}) {
313
+ return ['label', [
314
+ `Has property "${propertyName}": `,
315
+ ['select', {name, class: 'jsoeSearchTriState--'}, [
316
+ ['option', {value: ''}, ['(any)']],
317
+ ['option', {value: 'true'}, ['Has']],
318
+ ['option', {value: 'false'}, ['Doesn’t have']]
319
+ ]]
320
+ ]];
321
+ }
322
+
323
+ /**
324
+ * A generic "(any)"/true/false `<select>`, shared by every leaf whose only
325
+ * constraint is a plain boolean choice - `booleanSearchType.js`'s "true or
326
+ * false" (README) and `numberSearchType.js`'s "Is/Is Not Integer" both read
327
+ * back through this same three-way convention rather than each rolling
328
+ * their own.
329
+ *
330
+ * `key` distinguishes multiple tri-states *within one widget* (e.g.
331
+ * `dommatrixSearchType.js`'s "Is/Is not Readonly" and "Is/Is not 3d" side
332
+ * by side); see `buildRangeInputsPair`'s doc for why. Leave it at the
333
+ * default `''` for the common case of a widget with only one tri-state.
334
+ * `onChange`, when given, wires the select's own `change` event too (in
335
+ * addition to whatever the caller reads back via `readTriStateSelect` at
336
+ * `getQuery` time) - `dateSearchType.js`'s "Is valid/invalid date" tri-state
337
+ * uses it to disable the (otherwise irrelevant) From/To range while
338
+ * "Invalid date" is selected.
339
+ *
340
+ * `required`, when set, makes leaving the select at "(any)" invalid - the
341
+ * "(any)" option's `value: ''` below is exactly what native `required`
342
+ * treats as "nothing selected", the same trick `unionFamilySearchType.js`'s
343
+ * "Has type" select and `buildMultiSelect`'s `required` use.
344
+ * `booleanSearchType.js` passes this (a boolean widget has nothing else to
345
+ * constrain, so "(any)" is never a meaningful "no constraint" state - same
346
+ * "no absent values" reasoning as `buildLiteralRegexControls`'s Value
347
+ * input); other callers (`numberSearchType.js`'s "Is/Is Not Integer",
348
+ * `dommatrixSearchType.js`'s readonly/3d) leave it at the default `false`,
349
+ * since those are one of *several* facets in their own widget, where
350
+ * "(any)" legitimately means "no constraint on this facet".
351
+ * @param {{
352
+ * name: string, key?: string, trueLabel: string, falseLabel: string,
353
+ * onChange?: (this: HTMLElement) => void, required?: boolean
354
+ * }} cfg
355
+ * @returns {JamilihArray}
356
+ */
357
+ export function buildTriStateSelect ({
358
+ name, key = '', trueLabel, falseLabel, onChange, required = false
359
+ }) {
360
+ return ['select', {
361
+ name, class: `jsoeSearchTriState--${key}`, required,
362
+ $on: onChange ? {change: onChange} : undefined
363
+ }, [
364
+ ['option', {value: ''}, ['(any)']],
365
+ ['option', {value: 'true'}, [trueLabel]],
366
+ ['option', {value: 'false'}, [falseLabel]]
367
+ ]];
368
+ }
369
+
370
+ /**
371
+ * Reads back a `buildTriStateSelect`/`buildHasPropertyToggle` - pass the
372
+ * same `key` it was built with. `''` (any) maps to `undefined`.
373
+ * @param {Element} el
374
+ * @param {string} [key]
375
+ * @returns {boolean|undefined}
376
+ */
377
+ export function readTriStateSelect (el, key = '') {
378
+ const select = /** @type {HTMLSelectElement|undefined} */ (
379
+ findOwnControl(el, `select.jsoeSearchTriState--${key}`)
380
+ );
381
+ if (!select || select.value === '') {
382
+ return undefined;
383
+ }
384
+ return select.value === 'true';
385
+ }
386
+
387
+ /**
388
+ * A single checkbox - the only search affordance the README grants
389
+ * `undefined`/`void`/`null`/`NaN` ("Require present"; they have "no
390
+ * variants to allow for distinct search", since existence only becomes a
391
+ * meaningful question once the path is optional or nested in a union), and
392
+ * also used by `recordSearchType.js`/`mapSearchType.js` for their "require
393
+ * same entry" toggle.
394
+ *
395
+ * `checked`, when set, pre-checks the checkbox; `disabled` locks it there,
396
+ * non-interactive. `makePresenceOnlySearchType` passes both: the checkbox
397
+ * is that leaf's *only* possible constraint, and its whole schema type has
398
+ * exactly one value ("no variants to allow for distinct search" above) -
399
+ * there is no second state worth offering a choice between, so this simply
400
+ * asserts the one meaningful thing outright rather than making the user
401
+ * pointlessly check a box that could only ever mean one thing. "require
402
+ * same entry" leaves both at their `false` defaults (interactive,
403
+ * unchecked), since that's one optional facet alongside a map/record's own
404
+ * key/value matches, not the whole widget's sole, single-valued constraint.
405
+ * @param {{name: string, label: string, checked?: boolean, disabled?: boolean}} cfg
406
+ * @returns {JamilihArray}
407
+ */
408
+ export function buildCheckbox ({name, label, checked = false, disabled = false}) {
409
+ return ['label', [
410
+ `${label}: `,
411
+ ['input', {type: 'checkbox', name, class: 'jsoeSearchCheckbox', checked, disabled}]
412
+ ]];
413
+ }
414
+
415
+ /**
416
+ * Reads back a `buildCheckbox`.
417
+ * @param {HTMLElement} el
418
+ * @returns {boolean}
419
+ */
420
+ export function readCheckbox (el) {
421
+ return Boolean(/** @type {HTMLInputElement|undefined} */ (
422
+ findOwnControl(el, 'input.jsoeSearchCheckbox')
423
+ )?.checked);
424
+ }
425
+
426
+ /**
427
+ * The mode selector for the README's "string, StringObject, Blob, File,
428
+ * regexp (source), symbol (description): OR literal or regex search/Does
429
+ * Not contain search" - one shared control (and reader, below) that
430
+ * `stringSearchType.js`, `symbolSearchType.js`, and `regexpSearchType.js`
431
+ * (for its source) each build their own custom element around, since the
432
+ * query semantics are identical and only the label/target facet differs.
433
+ *
434
+ * `key` distinguishes multiple literal/regex controls *within one widget*
435
+ * (e.g. `errorSearchType.js`'s `message`/`name`/`fileName`/`stack`); see
436
+ * `buildRangeInputsPair`'s doc for why. Leave it at the default `''` for
437
+ * the common case of a widget with only one such control.
438
+ *
439
+ * The Value input is `required`: unlike an untouched range/checkbox/select
440
+ * (whose empty/default state unambiguously means "no constraint"), a mode
441
+ * is always selected here (there is no "(any)" option), so an empty Value
442
+ * next to it is never a meaningful "no constraint" state - it just means
443
+ * the row was added and never finished. That leaves the whole form invalid
444
+ * from the moment such a row exists (`buildSearchChoices`'s `<form>`,
445
+ * `src/search/index.js`) until either a value is entered or (for an
446
+ * `objectSearchType.js` has-property row) the row is removed via its own
447
+ * "Remove" button.
448
+ *
449
+ * `onModeChange`, when given, wires the mode select's own `change` event
450
+ * too (in addition to whatever the caller reads back via
451
+ * `readLiteralRegexQuery` at `getQuery` time) - `regexpSearchType.js` uses
452
+ * it to show/hide its Flags multi-select, which only makes sense while
453
+ * "Matches regex" is the chosen mode.
454
+ *
455
+ * `flagOptions`, when given, adds a Flags multi-select (options passed in
456
+ * by the caller - `stringSearchType.js` passes `regexpType.js`'s own
457
+ * `allowedFlags`, the same list `regexpSearchType.js` uses for the actual
458
+ * regexp's own flags - kept out of this generic module to avoid it
459
+ * depending on a specific fundamental type), shown only while "Matches
460
+ * regex" is the chosen mode, the same `$options` a Mongo-flavored `$regex`
461
+ * accepts alongside it - a plain literal/substring match has no regex to
462
+ * apply flags to, so it stays hidden otherwise. Every other caller
463
+ * (`errorSearchType.js`'s props, `fileSearchType.js`'s name/type,
464
+ * `domexceptionSearchType.js`'s message) leaves this unset, matching the
465
+ * README's flags-for-regexp-only-plus-string bullet.
466
+ * @param {{
467
+ * name: string, key?: string, onModeChange?: (this: HTMLElement) => void,
468
+ * flagOptions?: string[]
469
+ * }} cfg
470
+ * @returns {JamilihArray}
471
+ */
472
+ export function buildLiteralRegexControls ({name, key = '', onModeChange, flagOptions}) {
473
+ /**
474
+ * @this {HTMLElement}
475
+ * @returns {void}
476
+ */
477
+ function handleModeChange () {
478
+ const flagsLabel = this.closest('[data-search-path]')?.querySelector(
479
+ `.jsoeSearchRegexFlagsLabel--${key}`
480
+ );
481
+ if (flagsLabel) {
482
+ /** @type {HTMLElement} */ (flagsLabel).hidden =
483
+ /** @type {HTMLSelectElement} */ (this).value !== 'regex';
484
+ }
485
+ onModeChange?.call(this);
486
+ }
487
+ /** @type {JamilihArray[]} */
488
+ const flagsChildren = [];
489
+ if (flagOptions) {
490
+ flagsChildren.push(['label', {class: `jsoeSearchRegexFlagsLabel--${key}`, hidden: true}, [
491
+ 'Flags: ',
492
+ ['select', {
493
+ name: `${name}-flags`, multiple: true, class: `jsoeSearchRegexFlags--${key}`
494
+ }, flagOptions.map((flag) => ['option', {value: flag}, [flag]])]
495
+ ]]);
496
+ }
497
+ return ['span', [
498
+ ['label', [
499
+ 'Mode: ',
500
+ ['select', {
501
+ name: `${name}-mode`, class: `jsoeSearchMode--${key}`,
502
+ $on: {change: handleModeChange}
503
+ }, [
504
+ ['option', {value: 'literal'}, ['One of (comma-separated)']],
505
+ ['option', {value: 'regex'}, ['Matches regex']],
506
+ ['option', {value: 'notContains'}, ['Does not contain']]
507
+ ]]
508
+ ]],
509
+ ['label', [
510
+ 'Value: ',
511
+ ['input', {
512
+ type: 'text', name: `${name}-value`, class: `jsoeSearchValue--${key}`,
513
+ required: true
514
+ }]
515
+ ]],
516
+ ...flagsChildren
517
+ ]];
518
+ }
519
+
520
+ /**
521
+ * Reads back `buildLiteralRegexControls` into the corresponding
522
+ * `literalSet`/`regex`/`notContains` leaf - pass the same `key` it was
523
+ * built with. A `regex` leaf's `$options` is only ever populated when the
524
+ * caller built this with `flagOptions` (see that function's doc) and at
525
+ * least one flag is currently selected.
526
+ * @param {HTMLElement} el
527
+ * @param {string} path
528
+ * @param {string} [key]
529
+ * @returns {import('./queryTree.js').QueryLiteralSetLeaf|
530
+ * import('./queryTree.js').QueryRegexLeaf|
531
+ * import('./queryTree.js').QueryNotContainsLeaf|undefined}
532
+ */
533
+ export function readLiteralRegexQuery (el, path, key = '') {
534
+ const mode = /** @type {HTMLSelectElement|undefined} */ (
535
+ findOwnControl(el, `select.jsoeSearchMode--${key}`)
536
+ )?.value;
537
+ const value = /** @type {HTMLInputElement|undefined} */ (
538
+ findOwnControl(el, `input.jsoeSearchValue--${key}`)
539
+ )?.value;
540
+ if (!value) {
541
+ return undefined;
542
+ }
543
+ if (mode === 'regex') {
544
+ const flagsSelect = /** @type {HTMLSelectElement|undefined} */ (
545
+ findOwnControl(el, `select.jsoeSearchRegexFlags--${key}`)
546
+ );
547
+ const flags = [...(flagsSelect?.selectedOptions ?? [])].map((opt) => opt.value);
548
+ return {kind: 'regex', path, $regex: value, ...(flags.length ? {$options: flags.join('')} : {})};
549
+ }
550
+ if (mode === 'notContains') {
551
+ return {kind: 'notContains', path, value};
552
+ }
553
+ return {
554
+ kind: 'literalSet',
555
+ path,
556
+ $in: value.split(',').map((v) => v.trim()).filter(Boolean)
557
+ };
558
+ }
559
+
560
+ /**
561
+ * Has length/size of &lt;number&gt; (README; array/set/tuple-with-rest/
562
+ * filelist), + "Is/Is not sparse" for arrays only. Returns `[]` (no
563
+ * control) when the schema pins an exact length/size, since searching on a
564
+ * constant is uninteresting - the schema's `min`/`max` otherwise become the
565
+ * input's HTML `min`/`max` attributes.
566
+ * @param {{
567
+ * name: string,
568
+ * min?: number,
569
+ * max?: number,
570
+ * includeSparse?: boolean
571
+ * }} cfg
572
+ * @returns {JamilihArray[]}
573
+ */
574
+ export function buildLengthSizeControls ({name, min, max, includeSparse}) {
575
+ const fixed = min !== undefined && min === max;
576
+ /** @type {JamilihArray[]} */
577
+ const controls = [];
578
+ if (!fixed) {
579
+ controls.push(['label', [
580
+ 'Has length/size of: ',
581
+ ['input', {
582
+ type: 'number', name: `${name}-size`, class: 'jsoeSearchSize',
583
+ // A length/size is never negative, regardless of whether the
584
+ // schema itself declares a (necessarily non-negative) `min` -
585
+ // floor at 0 rather than leaving the input unbounded below when it
586
+ // doesn't.
587
+ min: min === undefined ? 0 : Math.max(min, 0), max, step: 1
588
+ }]
589
+ ]]);
590
+ }
591
+ if (includeSparse) {
592
+ controls.push(['label', [
593
+ 'Sparse: ',
594
+ buildTriStateSelect({
595
+ name: `${name}-sparse`, trueLabel: 'Sparse', falseLabel: 'Not sparse'
596
+ })
597
+ ]]);
598
+ }
599
+ return controls;
600
+ }
601
+
602
+ /**
603
+ * Reads back `buildLengthSizeControls` into one `lengthSize` leaf, or
604
+ * `undefined` if neither the size nor the sparse control was set.
605
+ * @param {Element} el
606
+ * @param {string} path
607
+ * @returns {import('./queryTree.js').QueryLengthSizeLeaf|undefined}
608
+ */
609
+ export function readLengthSizeQuery (el, path) {
610
+ const sizeStr = /** @type {HTMLInputElement|undefined} */ (
611
+ findOwnControl(el, 'input.jsoeSearchSize')
612
+ )?.value;
613
+ const sparseCheck = readTriStateSelect(el);
614
+ if (!sizeStr && sparseCheck === undefined) {
615
+ return undefined;
616
+ }
617
+ return {
618
+ kind: 'lengthSize',
619
+ path,
620
+ ...(sizeStr ? {$size: Number(sizeStr)} : {}),
621
+ ...(sparseCheck === undefined ? {} : {sparseCheck})
622
+ };
623
+ }
624
+
625
+ /**
626
+ * Wraps arbitrary markup - a recursed child search widget's array, or a
627
+ * fixed facet's own controls (`buildLiteralRegexControls`'s output, say) -
628
+ * in an opt-in "Search on this" checkbox around a disabled `<fieldset>`.
629
+ * For a facet that's one of *several* independent, individually-optional
630
+ * constraints within a parent widget (array/set/tuple/filelist's element
631
+ * match(es); map/record's key/value; `fileSearchType.js`'s name/content-
632
+ * type), this keeps that facet's own `required` controls (if it has any)
633
+ * from forcing the *whole* form invalid just by the facet existing -
634
+ * generalizes `objectSearchType.js`'s original required-property row (see
635
+ * that file's history) into a shared helper once enough call sites needed
636
+ * the identical checkbox+fieldset shape.
637
+ *
638
+ * `key` distinguishes multiple opt-in fieldsets *within one widget* (e.g.
639
+ * `mapSearchType.js`'s "key" and "value"); see `buildRangeInputsPair`'s doc
640
+ * for why. Leave it at the default `''` for a widget with only one.
641
+ *
642
+ * The checkbox's own `name` attribute is `${name}-optIn`, not the bare
643
+ * `name` passed in: a caller's `name` is typically shared with (a prefix
644
+ * of) the wrapped content's own field names (`mapSearchType.js`'s "value"
645
+ * facet, say, names its checkbox from the same `name` its inner value
646
+ * widget builds its own `-value`-suffixed `<input>` from), and callers
647
+ * cannot always predict what suffix a given recursed `SearchTypeObject`
648
+ * will pick - the fixed `-optIn` suffix here guarantees no collision
649
+ * regardless, without every caller needing to reason about it.
650
+ *
651
+ * Call `wireOptInFieldset` once, right after this is built into real DOM
652
+ * (e.g. inside a `jml(...)` caller, same as `objectSearchType.js`'s rows
653
+ * do), to actually connect the checkbox to the fieldset's `disabled` state -
654
+ * this function only builds the static markup (default unchecked/disabled).
655
+ * @param {{
656
+ * name: string, key?: string, label: string, children: JamilihArray[]
657
+ * }} cfg
658
+ * @returns {JamilihArray[]}
659
+ */
660
+ export function buildOptInFieldset ({name, key = '', label, children}) {
661
+ return [
662
+ ['label', {class: 'jsoeSearchOptInLabel'}, [
663
+ `${label}: `,
664
+ ['input', {type: 'checkbox', name: `${name}-optIn`, class: `jsoeSearchOptIn--${key}`}]
665
+ ]],
666
+ ['fieldset', {disabled: true, class: `jsoeSearchOptInFieldset--${key}`}, children]
667
+ ];
668
+ }
669
+
670
+ /**
671
+ * Reads back a `buildOptInFieldset`'s checkbox - pass the same `key` it was
672
+ * built with.
673
+ * @param {Element} root
674
+ * @param {string} [key]
675
+ * @returns {boolean}
676
+ */
677
+ export function readOptInChecked (root, key = '') {
678
+ return Boolean(/** @type {HTMLInputElement|undefined} */ (
679
+ findOwnControl(root, `input.jsoeSearchOptIn--${key}`)
680
+ )?.checked);
681
+ }
682
+
683
+ /**
684
+ * Connects a `buildOptInFieldset`'s checkbox to toggle its own fieldset's
685
+ * `disabled` state - call once, synchronously, right after both are live
686
+ * DOM nodes (see that function's doc). Pass the same `key` it was built
687
+ * with.
688
+ *
689
+ * `onToggle`, when given, is called after each toggle (with the checkbox as
690
+ * `this`) - `mapSearchType.js`/`recordSearchType.js`/`fileSearchType.js`
691
+ * use it to re-run their own `syncAtLeastOneCheck` whenever one of their
692
+ * (exactly two) opt-in facets changes, since checking/unchecking either one
693
+ * can change whether "at least one" is satisfied.
694
+ * @param {Element} root
695
+ * @param {string} [key]
696
+ * @param {((this: HTMLInputElement) => void)} [onToggle]
697
+ * @returns {void}
698
+ */
699
+ export function wireOptInFieldset (root, key = '', onToggle = undefined) {
700
+ const checkbox = /** @type {HTMLInputElement|undefined} */ (
701
+ findOwnControl(root, `input.jsoeSearchOptIn--${key}`)
702
+ );
703
+ const fieldset = /** @type {HTMLFieldSetElement|undefined} */ (
704
+ findOwnControl(root, `fieldset.jsoeSearchOptInFieldset--${key}`)
705
+ );
706
+ checkbox?.addEventListener('change', () => {
707
+ if (fieldset) {
708
+ fieldset.disabled = !checkbox.checked;
709
+ }
710
+ onToggle?.call(checkbox);
711
+ });
712
+ }
713
+
714
+ /**
715
+ * A visually-hidden but still-rendered (and so still constraint-validation-
716
+ * eligible - see `jsoe.css`'s `.searchAtLeastOneSentinel`, the same
717
+ * "visually-hidden" pattern used for accessibility, which keeps an element
718
+ * off-screen without `display: none`/`hidden` triggering the Constraint
719
+ * Validation API's own "not rendered" exemption) sentinel control, for a
720
+ * widget with several independent optional facets where leaving *every one*
721
+ * unconfigured should be invalid even though no single facet is itself
722
+ * always required (`mapSearchType.js`/`recordSearchType.js`'s key/value,
723
+ * `fileSearchType.js`'s name/content-type). Pair with `syncAtLeastOneCheck`.
724
+ * @returns {JamilihArray}
725
+ */
726
+ export function buildAtLeastOneSentinel () {
727
+ return ['input', {
728
+ type: 'text', class: 'searchAtLeastOneSentinel', tabindex: -1,
729
+ 'aria-hidden': 'true'
730
+ }];
731
+ }
732
+
733
+ /**
734
+ * @type {WeakMap<Element, () => void>}
735
+ */
736
+ const atLeastOneResyncs = new WeakMap();
737
+
738
+ /**
739
+ * Sets a `buildAtLeastOneSentinel`'s custom validity from a live check -
740
+ * call once at `connectedCallback` time for the initial state (a freshly-
741
+ * built widget has satisfied none of its facets yet) and again whenever a
742
+ * facet that could change the answer does (an opt-in checkbox toggling, a
743
+ * recursed child's own `input`/`change`).
744
+ *
745
+ * Also relaxed, independently of `isSatisfied`, while
746
+ * `isExemptedByAncestorHasProperty` says an enclosing
747
+ * `objectSearchType.js` has-property row's own "Has"/"Doesn't have"
748
+ * already provides a complete constraint - the same relief
749
+ * `syncRangeValidity` and `dateSearchType.js`'s own range validator give
750
+ * their "at least one bound" rule, needed here too since a has-property
751
+ * row's own child could just as easily be an `array`/`map`/`file`/etc.
752
+ * whose *own* sole constraint is this same "at least one facet" sentinel
753
+ * rather than a plain `required` attribute or a range pair.
754
+ *
755
+ * Every call re-registers `isSatisfied` (keyed by `root` in a `WeakMap`, so
756
+ * it never leaks past the element's own lifetime) for `resyncAtLeastOne` to
757
+ * call later. This exists because not every "at least one" widget's own
758
+ * answer changes via a plain `input`/`change` event - `objectSearchType.js`
759
+ * itself is the reason: its sentinel changes when a row is added/removed
760
+ * (a button click, and a structural DOM change, not a value change on any
761
+ * control), which `revalidateDescendants`' blanket event-redispatch can
762
+ * never reach, unlike `array`/`map`/etc.'s own opt-in checkboxes and size
763
+ * inputs. `objectSearchType.js`'s `buildHasPropertyRow` calls
764
+ * `resyncAtLeastOne` directly instead, precisely because it can't rely on
765
+ * redispatched events reaching every possible child type.
766
+ * @param {Element} root
767
+ * @param {() => boolean} isSatisfied
768
+ * @returns {void}
769
+ */
770
+ export function syncAtLeastOneCheck (root, isSatisfied) {
771
+ atLeastOneResyncs.set(root, () => syncAtLeastOneCheck(root, isSatisfied));
772
+ const sentinel = /** @type {HTMLInputElement|undefined} */ (
773
+ findOwnControl(root, 'input.searchAtLeastOneSentinel')
774
+ );
775
+ sentinel?.setCustomValidity(
776
+ isSatisfied() || isExemptedByAncestorHasProperty(root)
777
+ ? ''
778
+ : 'Configure at least one of this widget’s facets.'
779
+ );
780
+ }
781
+
782
+ /**
783
+ * Re-runs every `syncAtLeastOneCheck` registered anywhere within `root`
784
+ * (itself included) - see that function's doc for why this exists
785
+ * alongside `revalidateDescendants` rather than relying on it alone.
786
+ * @param {Element} root
787
+ * @returns {void}
788
+ */
789
+ export function resyncAtLeastOne (root) {
790
+ atLeastOneResyncs.get(root)?.();
791
+ [...root.querySelectorAll('[data-search-path]')].forEach((el) => {
792
+ atLeastOneResyncs.get(el)?.();
793
+ });
794
+ }
795
+
796
+ /**
797
+ * Toggles the native `required` attribute on every originally-`required`
798
+ * control inside `root`, leaving everything else (visibility, `disabled`)
799
+ * alone - unlike `buildOptInFieldset`'s `<fieldset disabled>`, this keeps
800
+ * the whole subtree fully interactive, for a case where an ancestor's own
801
+ * choice already provides a complete constraint on its own, but should
802
+ * still let the recursed child *optionally* add a further constraint on
803
+ * top rather than blocking interaction with it entirely.
804
+ * `objectSearchType.js`'s `buildHasPropertyRow` uses this for its "Has"/
805
+ * "Doesn't have" tri-state: once existence is explicitly asserted either
806
+ * way, the child's own required inputs (whatever type it turns out to be)
807
+ * should stop forcing it to be filled in, while a user who still wants to
808
+ * combine "Has property X" with "X matches Y" can keep typing into it -
809
+ * `dateSearchType.js`'s "Valid"/"Invalid date" tri-state needs the
810
+ * equivalent relaxation for its own (structurally different, so not built
811
+ * through this helper) range pair.
812
+ *
813
+ * Relaxing (`required: false`) marks each control it touches with
814
+ * `data-jsoe-was-required` before clearing the IDL `required` property -
815
+ * setting that property to `false` reflects back to *removing* the
816
+ * `required` content attribute entirely (standard boolean-attribute
817
+ * reflection), so a later call to restore it can't rely on `[required]`
818
+ * still matching that element; it looks for the marker instead.
819
+ *
820
+ * Skips any control inside a `buildOptInFieldset` fieldset
821
+ * (`jsoeSearchOptInFieldset--*`): that facet already has its own gate on
822
+ * whether it applies at all (the checkbox's `disabled` toggle exempts it
823
+ * from constraint validation while unchecked), so an enclosing "Has
824
+ * property" shouldn't also strip its `required` - a user who explicitly
825
+ * opts into e.g. `fileSearchType.js`'s "Name" facet under a "Has property"
826
+ * ancestor still needs to fill in the Value it requires, or uncheck "Name"
827
+ * again; only the widget's own `buildAtLeastOneSentinel` (which *is*
828
+ * exempted, via `isExemptedByAncestorHasProperty`) should be satisfied by
829
+ * "Has property" alone.
830
+ * @param {Element} root
831
+ * @param {boolean} required
832
+ * @returns {void}
833
+ */
834
+ export function setDescendantsRequired (root, required) {
835
+ const selector = required ? '[data-jsoe-was-required]' : '[required]';
836
+ [...root.querySelectorAll(selector)].forEach((el) => {
837
+ if (el.closest('fieldset[class^="jsoeSearchOptInFieldset--"]')) {
838
+ return;
839
+ }
840
+ if (!required) {
841
+ /** @type {HTMLElement} */ (el).dataset.jsoeWasRequired = 'true';
842
+ }
843
+ /** @type {HTMLInputElement|HTMLSelectElement|HTMLTextAreaElement} */ (
844
+ el
845
+ ).required = required;
846
+ });
847
+ }