fluid-primitives 0.19.1 → 1.0.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -0
- package/dist/Client-HrMIqv7d.js +1934 -0
- package/dist/accordion.d.ts +2 -2
- package/dist/accordion.js +25 -29
- package/dist/button.d.ts +52 -0
- package/dist/button.js +135 -0
- package/dist/checkbox-group.d.ts +6 -5
- package/dist/checkbox-group.js +36 -20
- package/dist/{checkbox-group.registry-CGwuF7SF.js → checkbox-group.registry-D8P8A4LY.js} +13 -8
- package/dist/checkbox.d.ts +18 -4
- package/dist/checkbox.js +30 -27
- package/dist/client.d.ts +2 -2
- package/dist/client.js +2 -2
- package/dist/clipboard.d.ts +12 -5
- package/dist/clipboard.js +14 -16
- package/dist/collapsible.d.ts +3 -3
- package/dist/collapsible.js +13 -17
- package/dist/combobox.d.ts +37 -0
- package/dist/combobox.js +151 -0
- package/dist/date-picker.d.ts +67 -0
- package/dist/date-picker.js +289 -0
- package/dist/dialog.d.ts +3 -3
- package/dist/dialog.js +11 -12
- package/dist/field-array.d.ts +97 -0
- package/dist/field-array.js +223 -0
- package/dist/field.d.ts +30 -54
- package/dist/field.js +589 -175
- package/dist/fieldset.d.ts +15 -0
- package/dist/fieldset.js +27 -0
- package/dist/file-upload.d.ts +85 -0
- package/dist/file-upload.js +255 -0
- package/dist/form.d.ts +2 -83
- package/dist/form.fields-B58TZukv.js +329 -0
- package/dist/form.js +263 -276
- package/dist/index-B06gIN_k.d.ts +1358 -0
- package/dist/input.d.ts +96 -0
- package/dist/input.js +233 -0
- package/dist/menu.d.ts +55 -0
- package/dist/menu.js +144 -0
- package/dist/navigation-menu.d.ts +3 -3
- package/dist/navigation-menu.js +29 -43
- package/dist/number-input.d.ts +13 -4
- package/dist/number-input.js +30 -31
- package/dist/popover.d.ts +11 -3
- package/dist/popover.js +17 -18
- package/dist/radio-group.d.ts +4 -4
- package/dist/radio-group.js +35 -42
- package/dist/scroll-area.d.ts +3 -3
- package/dist/scroll-area.js +10 -9
- package/dist/select.d.ts +13 -51
- package/dist/select.js +62 -55
- package/dist/slider.d.ts +27 -0
- package/dist/slider.js +71 -0
- package/dist/switch.d.ts +4 -4
- package/dist/switch.js +24 -33
- package/dist/tabs.d.ts +3 -3
- package/dist/tabs.js +12 -15
- package/dist/textarea.d.ts +101 -0
- package/dist/textarea.js +231 -0
- package/dist/tooltip.d.ts +11 -3
- package/dist/tooltip.js +11 -10
- package/package.json +79 -24
- package/dist/Client-C51NSHxg.js +0 -248
- package/dist/field.dom-CJQXpQbZ.js +0 -11
- package/dist/form.registry-Cb8Pvoe8.d.ts +0 -7
- package/dist/form.registry-CmpTny_s.js +0 -51
- package/dist/index-B8JCdyld.d.ts +0 -118
|
@@ -0,0 +1,1934 @@
|
|
|
1
|
+
import * as asyncList from "@zag-js/async-list";
|
|
2
|
+
import { VanillaMachine, mergeProps, normalizeProps, normalizeProps as normalizeProps$1, spreadProps } from "@zag-js/vanilla";
|
|
3
|
+
import { ListCollection } from "@zag-js/collection";
|
|
4
|
+
import { dataAttr } from "@zag-js/dom-query";
|
|
5
|
+
import { match, uuid } from "@zag-js/utils";
|
|
6
|
+
import { createAnatomy } from "@zag-js/anatomy";
|
|
7
|
+
|
|
8
|
+
//#region Resources/Private/Client/src/lib/machine.ts
|
|
9
|
+
/**
|
|
10
|
+
* The Zag machine runtime that components run on. It takes `Partial` props: the `id` that Zag
|
|
11
|
+
* requires is guaranteed at runtime by `Component.initHydrator()`.
|
|
12
|
+
*/
|
|
13
|
+
var Machine = class extends VanillaMachine {
|
|
14
|
+
constructor(machine, userProps) {
|
|
15
|
+
super(machine, userProps);
|
|
16
|
+
}
|
|
17
|
+
/** Changes props at runtime. Only the ones you pass change, the others stay. */
|
|
18
|
+
updateProps(newProps) {
|
|
19
|
+
super.updateProps(newProps);
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
//#endregion
|
|
24
|
+
//#region Resources/Private/Client/src/lib/async-list.ts
|
|
25
|
+
/**
|
|
26
|
+
* Wraps the machine of `@zag-js/async-list` for use without any DOM or hydration: construct it, call
|
|
27
|
+
* `init()`, `subscribe()` to state changes and `destroy()` when done. Rendering is yours, do it from
|
|
28
|
+
* inside `subscribe()`.
|
|
29
|
+
*
|
|
30
|
+
* Debouncing or throttling `setFilter()` is left to the caller, wrap the call site with the
|
|
31
|
+
* `debounce` or `throttle` of `@zag-js/utils`, which this library already depends on.
|
|
32
|
+
*/
|
|
33
|
+
var AsyncList = class {
|
|
34
|
+
machine;
|
|
35
|
+
constructor(options) {
|
|
36
|
+
this.machine = new Machine(asyncList.machine, options);
|
|
37
|
+
}
|
|
38
|
+
/** The api of the current state of the list. It is read fresh on every access, don't hold on to it. */
|
|
39
|
+
get api() {
|
|
40
|
+
return asyncList.connect(this.machine.service);
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Calls `fn` with the api on every future state change. It does not fire for the current state,
|
|
44
|
+
* read `api` first if you need it.
|
|
45
|
+
*
|
|
46
|
+
* @param fn - The listener.
|
|
47
|
+
* @returns A function that removes the listener.
|
|
48
|
+
*/
|
|
49
|
+
subscribe(fn) {
|
|
50
|
+
return this.machine.subscribe((service) => fn(asyncList.connect(service)));
|
|
51
|
+
}
|
|
52
|
+
/** Sets the filter and reloads the list with it. */
|
|
53
|
+
setFilter(filter) {
|
|
54
|
+
this.api.setFilter(filter);
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Changes options at runtime, e.g. `dependencies` to trigger an `autoReload`. `load` is fixed
|
|
58
|
+
* when the list is constructed and can't be swapped here.
|
|
59
|
+
*/
|
|
60
|
+
updateProps(newProps) {
|
|
61
|
+
this.machine.updateProps(newProps);
|
|
62
|
+
}
|
|
63
|
+
/** Starts the machine, which loads the list for the first time. Call it once, after constructing. */
|
|
64
|
+
init() {
|
|
65
|
+
this.machine.start();
|
|
66
|
+
}
|
|
67
|
+
/** Stops the machine and cancels a load that is in flight. */
|
|
68
|
+
destroy() {
|
|
69
|
+
this.machine.stop();
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
//#endregion
|
|
74
|
+
//#region Resources/Private/Client/src/lib/component.ts
|
|
75
|
+
/**
|
|
76
|
+
* Base class of every primitive. A subclass builds its machine in `initMachine()`, connects it in
|
|
77
|
+
* `initApi()` and spreads the props of its parts onto the DOM in `render()`. Call `init()` once to
|
|
78
|
+
* start it.
|
|
79
|
+
*
|
|
80
|
+
* @example
|
|
81
|
+
* ```typescript
|
|
82
|
+
* class Counter extends Component<CounterProps, CounterApi> {
|
|
83
|
+
* static componentName = 'counter';
|
|
84
|
+
*
|
|
85
|
+
* initMachine(props: CounterProps) {
|
|
86
|
+
* return new Machine(counter.machine, props);
|
|
87
|
+
* }
|
|
88
|
+
*
|
|
89
|
+
* initApi() {
|
|
90
|
+
* return counter.connect(this.machine.service, normalizeProps);
|
|
91
|
+
* }
|
|
92
|
+
*
|
|
93
|
+
* render() {
|
|
94
|
+
* const rootEl = this.hydrator.query('root');
|
|
95
|
+
* if (rootEl) this.spreadProps(rootEl, this.api.getRootProps());
|
|
96
|
+
* }
|
|
97
|
+
* }
|
|
98
|
+
* ```
|
|
99
|
+
*/
|
|
100
|
+
var Component = class {
|
|
101
|
+
/** The document the component renders into. */
|
|
102
|
+
document;
|
|
103
|
+
#machine;
|
|
104
|
+
#api;
|
|
105
|
+
#hydrator;
|
|
106
|
+
#userProps;
|
|
107
|
+
/**
|
|
108
|
+
* The Fluid namespace identifier (e.g. "ui") this instance was mounted under - assigned by
|
|
109
|
+
* `mountAll`/`mount` right after their callback returns, so not yet available while it runs
|
|
110
|
+
* (`init()` included).
|
|
111
|
+
*/
|
|
112
|
+
namespace;
|
|
113
|
+
/** The name of the component, in camelCase. Every subclass has to set it. */
|
|
114
|
+
static componentName;
|
|
115
|
+
/** Alias of `document`. */
|
|
116
|
+
get doc() {
|
|
117
|
+
return this.document;
|
|
118
|
+
}
|
|
119
|
+
constructor(initialProps, userDocument = document) {
|
|
120
|
+
this.initialProps = initialProps;
|
|
121
|
+
this.document = userDocument;
|
|
122
|
+
}
|
|
123
|
+
/** Created by {@see init} - reading it earlier throws. */
|
|
124
|
+
get machine() {
|
|
125
|
+
return this.#requireInit(this.#machine, "machine");
|
|
126
|
+
}
|
|
127
|
+
/** Created by {@see init} - reading it earlier throws. */
|
|
128
|
+
get api() {
|
|
129
|
+
return this.#requireInit(this.#api, "api");
|
|
130
|
+
}
|
|
131
|
+
set api(api) {
|
|
132
|
+
this.#api = api;
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Finds the component's parts by name, see {@see ComponentHydrator.query}. Created by
|
|
136
|
+
* {@see init} - reading it earlier throws.
|
|
137
|
+
*/
|
|
138
|
+
get hydrator() {
|
|
139
|
+
return this.#requireInit(this.#hydrator, "hydrator");
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* The props the instance runs on - what {@see transformProps} made of the ones it was created
|
|
143
|
+
* with. Created by {@see init} - reading it earlier throws.
|
|
144
|
+
*/
|
|
145
|
+
get userProps() {
|
|
146
|
+
return this.#requireInit(this.#userProps, "userProps");
|
|
147
|
+
}
|
|
148
|
+
#requireInit(value, member) {
|
|
149
|
+
if (value === void 0) throw new Error(`${this.getName()}: \`${member}\` was accessed before init(). It only exists once init() has run - call init() first.`);
|
|
150
|
+
return value;
|
|
151
|
+
}
|
|
152
|
+
/** Creates the hydrator that finds the parts of the component. It needs the `id` prop. */
|
|
153
|
+
initHydrator(props) {
|
|
154
|
+
const id = props.id;
|
|
155
|
+
if (!id) throw new Error("ComponentHydrator requires an id prop to initialize.");
|
|
156
|
+
return new ComponentHydrator(this.getName(), id, this.doc);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Builds everything the instance runs on - `userProps`, hydrator, machine and api - then renders
|
|
160
|
+
* once and starts the machine. Call it once after constructing.
|
|
161
|
+
*/
|
|
162
|
+
init() {
|
|
163
|
+
this.#userProps = this.transformProps(this.initialProps);
|
|
164
|
+
this.#hydrator = this.initHydrator(this.#userProps);
|
|
165
|
+
this.#machine = this.initMachine(this.#userProps);
|
|
166
|
+
this.#api = this.initApi();
|
|
167
|
+
this.render();
|
|
168
|
+
this.#machine.subscribe(() => {
|
|
169
|
+
this.#api = this.initApi();
|
|
170
|
+
this.render();
|
|
171
|
+
});
|
|
172
|
+
this.#machine.start();
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Renders again with no prop change behind it. Use it after changing the DOM directly, e.g.
|
|
176
|
+
* after inserting a new `Template` instance, for a primitive with no prop that causes a render on
|
|
177
|
+
* its own. Combobox and Select don't need it: `updateProps({ collection })` already renders.
|
|
178
|
+
*/
|
|
179
|
+
refresh() {
|
|
180
|
+
this.machine.notify();
|
|
181
|
+
}
|
|
182
|
+
/** The `componentName` of the class. */
|
|
183
|
+
getName() {
|
|
184
|
+
return this.constructor.componentName;
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* Kebab form of {@see getName} - what actually appears in DOM-facing identifiers (part attribute
|
|
188
|
+
* names, hydration keys). See {@see ComponentHydrator.clientComponentName}.
|
|
189
|
+
*/
|
|
190
|
+
getClientName() {
|
|
191
|
+
return toKebabCase(this.getName());
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* Override to adjust the props before anything else sees them, e.g. to wrap a callback prop.
|
|
195
|
+
* Runs once, first in {@see init}: the result becomes `userProps`, which `initHydrator()` and
|
|
196
|
+
* `initMachine()` receive.
|
|
197
|
+
*/
|
|
198
|
+
transformProps(props) {
|
|
199
|
+
return props;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Another already-mounted instance of this same component, by root id - for a primitive that
|
|
203
|
+
* composes two independent instances of itself (Menu submenus). Only resolves once
|
|
204
|
+
* `mountAll`/`mount` assigned {@see namespace}.
|
|
205
|
+
*/
|
|
206
|
+
getPeerInstance(rootId) {
|
|
207
|
+
return this.namespace ? getComponentInstance(`${this.namespace}:${this.getName()}`, rootId) : void 0;
|
|
208
|
+
}
|
|
209
|
+
/** Changes props at runtime. Only the ones you pass change, the others stay. */
|
|
210
|
+
updateProps(newProps) {
|
|
211
|
+
this.machine.updateProps(newProps);
|
|
212
|
+
}
|
|
213
|
+
/** Stops the machine and releases the hydrator. */
|
|
214
|
+
destroy() {
|
|
215
|
+
this.machine.stop();
|
|
216
|
+
this.hydrator.destroy();
|
|
217
|
+
}
|
|
218
|
+
/** Applies the attributes and event handlers of a part's props to its element. */
|
|
219
|
+
spreadProps(node, attrs) {
|
|
220
|
+
spreadProps(node, attrs, this.machine.scope.id);
|
|
221
|
+
}
|
|
222
|
+
/** {@see spreadTextControlProps}: for a text control that mirrors what the user typed. */
|
|
223
|
+
spreadTextControlProps(node, attrs) {
|
|
224
|
+
spreadTextControlProps(node, attrs, this.machine.scope.id);
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* For every element matching `part`, reads its own `data-value` and hands `{el, value}` to
|
|
228
|
+
* `getProps`; if it returns a props object, spreads it onto that element via `spreadProps`.
|
|
229
|
+
* Returning `null`/`undefined` skips the element (e.g. a value that no longer resolves
|
|
230
|
+
* against a collection). Deliberately does not read any other attribute
|
|
231
|
+
* (disabled/invalid/current/...) or resolve a collection itself - callers read whatever flags
|
|
232
|
+
* they need off `el` and do their own value resolution, since both vary per primitive.
|
|
233
|
+
*/
|
|
234
|
+
spreadPropsByValue(part, getProps, options) {
|
|
235
|
+
this.hydrator.queryAll(part, options?.parent).forEach((el) => {
|
|
236
|
+
const value = el.dataset.value;
|
|
237
|
+
if (value === void 0) return;
|
|
238
|
+
const props = getProps({
|
|
239
|
+
el,
|
|
240
|
+
value
|
|
241
|
+
});
|
|
242
|
+
if (props) this.spreadProps(el, props);
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
spreadPropsByOptionalValue(part, getProps, options) {
|
|
246
|
+
this.hydrator.queryAll(part, options?.parent).forEach((el) => {
|
|
247
|
+
const value = el.dataset.value;
|
|
248
|
+
const props = getProps({
|
|
249
|
+
el,
|
|
250
|
+
value
|
|
251
|
+
});
|
|
252
|
+
if (props) this.spreadProps(el, props);
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
//#endregion
|
|
258
|
+
//#region Resources/Private/Client/src/lib/portal.ts
|
|
259
|
+
const wrappersByOrigin = /* @__PURE__ */ new WeakMap();
|
|
260
|
+
/**
|
|
261
|
+
* @internal
|
|
262
|
+
*
|
|
263
|
+
* Moves every `[data-portal]` wrapper inside `root` to its destination. A wrapper whose container
|
|
264
|
+
* does not exist stays where it is, unless it is the default portal, which goes to the end of the body.
|
|
265
|
+
*/
|
|
266
|
+
function movePortals(root, doc = document) {
|
|
267
|
+
for (const wrapper of root.querySelectorAll("[data-portal]")) {
|
|
268
|
+
const name = wrapper.dataset.portal || "default";
|
|
269
|
+
const container = Array.from(doc.querySelectorAll("template[data-portal-container]")).find((el) => el.dataset.portalContainer === name);
|
|
270
|
+
if (!container && name !== "default") continue;
|
|
271
|
+
const origin = doc.createElement("template");
|
|
272
|
+
origin.setAttribute("data-portal-origin", "");
|
|
273
|
+
wrapper.replaceWith(origin);
|
|
274
|
+
wrappersByOrigin.set(origin, wrapper);
|
|
275
|
+
if (container) container.after(wrapper);
|
|
276
|
+
else doc.body.append(wrapper);
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
/**
|
|
280
|
+
* @internal
|
|
281
|
+
*
|
|
282
|
+
* Removes the portals that `root` moved out of itself, call it before removing `root`.
|
|
283
|
+
*/
|
|
284
|
+
function removePortalsWithin(root) {
|
|
285
|
+
for (const origin of root.querySelectorAll("template[data-portal-origin]")) {
|
|
286
|
+
const wrapper = wrappersByOrigin.get(origin);
|
|
287
|
+
if (!wrapper) continue;
|
|
288
|
+
removePortalsWithin(wrapper);
|
|
289
|
+
wrapper.remove();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
//#endregion
|
|
294
|
+
//#region Resources/Private/Client/src/lib/hydration.ts
|
|
295
|
+
/**
|
|
296
|
+
* @internal
|
|
297
|
+
*
|
|
298
|
+
* A part's own name is lowerCamelCase (mirroring zag-js's own `ids` prop keys), but its attribute
|
|
299
|
+
* always renders lower-kebab. Keep in sync with: Classes/Utility/ComponentNameUtility.php's
|
|
300
|
+
* camelCaseToLowerCaseDashed().
|
|
301
|
+
*/
|
|
302
|
+
function toKebabCase(part) {
|
|
303
|
+
return part.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* Splits a required `"namespace:name"` string (e.g. `"ui:select"`) into its two parts - every
|
|
307
|
+
* public lookup below (`getHydrationData`, `mountAll`, `mount`, `getComponentInstance`) requires
|
|
308
|
+
* this form, with no bare-name fallback: two collections can legitimately define a same-named
|
|
309
|
+
* component with a different shape (a styled wrapper around a primitive it doesn't expose every
|
|
310
|
+
* prop of), so the caller always states which one it means.
|
|
311
|
+
*/
|
|
312
|
+
function parseNamespacedComponentName(componentName) {
|
|
313
|
+
const separatorIndex = componentName.indexOf(":");
|
|
314
|
+
if (separatorIndex === -1) throw new Error(`[fluid-primitives] "${componentName}" must include a namespace, e.g. "ui:${componentName}" - mountAll/mount/getHydrationData/getComponentInstance always require one.`);
|
|
315
|
+
return {
|
|
316
|
+
namespace: componentName.slice(0, separatorIndex),
|
|
317
|
+
baseName: componentName.slice(separatorIndex + 1)
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
function getHydrationData(component, id) {
|
|
321
|
+
const hydrationData = window.FluidPrimitives?.hydrationData;
|
|
322
|
+
if (!hydrationData || typeof hydrationData !== "object") return null;
|
|
323
|
+
if (!component) return hydrationData;
|
|
324
|
+
const { namespace, baseName } = parseNamespacedComponentName(component);
|
|
325
|
+
const clientBaseName = toKebabCase(baseName);
|
|
326
|
+
let instances = hydrationData[namespace];
|
|
327
|
+
for (const segment of clientBaseName.split(".")) {
|
|
328
|
+
if (!instances || typeof instances !== "object") return null;
|
|
329
|
+
instances = instances[segment];
|
|
330
|
+
}
|
|
331
|
+
if (!instances) return null;
|
|
332
|
+
if (!id) return instances;
|
|
333
|
+
return instances[id] || null;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Root components PHP found nested inside the tracked scope `scopeId` (a `ui:template` stencil's
|
|
337
|
+
* own id, or a FieldArray row's own id) - see `HydrationRegistry::recordNestedComponent()`'s own
|
|
338
|
+
* docblock for why this is authoritative, PHP-recorded data rather than something inferred from
|
|
339
|
+
* scanning rendered DOM shape (which can't find a component that renders no DOM element of its own,
|
|
340
|
+
* e.g. `Dialog`'s `Root`, or whose content has since portaled elsewhere).
|
|
341
|
+
*/
|
|
342
|
+
function getNestedComponents(scopeId) {
|
|
343
|
+
return window.FluidPrimitives?.nestedComponents?.[scopeId] ?? [];
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Finds a component instance that is already mounted, by the namespaced name it was mounted with and
|
|
347
|
+
* its hydration id (the `id` prop it was rendered with). Works for instances from `mountAll()` and
|
|
348
|
+
* `mount()` alike. It is for primitives that compose two independent instances of themselves at
|
|
349
|
+
* runtime, e.g. the submenus of a Menu.
|
|
350
|
+
*
|
|
351
|
+
* @param componentName - The namespaced name, e.g. `ui:menu`.
|
|
352
|
+
* @param id - The hydration id of the instance.
|
|
353
|
+
* @returns The instance, or `undefined` if none is mounted under that id.
|
|
354
|
+
*/
|
|
355
|
+
function getComponentInstance(componentName, id) {
|
|
356
|
+
const { namespace, baseName } = parseNamespacedComponentName(componentName);
|
|
357
|
+
return window.FluidPrimitives?.componentInstances?.[namespace]?.[toKebabCase(baseName)]?.[id];
|
|
358
|
+
}
|
|
359
|
+
/** @internal */
|
|
360
|
+
function getGlobals() {
|
|
361
|
+
const globals = window.FluidPrimitives?.globals;
|
|
362
|
+
if (!globals || typeof globals !== "object") return null;
|
|
363
|
+
return globals;
|
|
364
|
+
}
|
|
365
|
+
/** @internal */
|
|
366
|
+
function getGlobal(key) {
|
|
367
|
+
const globals = getGlobals();
|
|
368
|
+
if (!globals || !(key in globals)) return;
|
|
369
|
+
return globals[key];
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Mounts every instance of a component that is not mounted yet and was not rendered with
|
|
373
|
+
* `autoMount="{false}"`. It is safe to call more than once, e.g. after lazily inserted DOM adds new
|
|
374
|
+
* instances: mounted ones are skipped.
|
|
375
|
+
*
|
|
376
|
+
* The `props` in the callback are typed from `componentName` via `HydrationPropsFor`. A component
|
|
377
|
+
* without generated types gets an untyped bag.
|
|
378
|
+
*
|
|
379
|
+
* @param componentName - The namespaced name, e.g. `ui:select`.
|
|
380
|
+
* @param callback - Creates the instance of one hydration entry and returns it.
|
|
381
|
+
*
|
|
382
|
+
* @example
|
|
383
|
+
* ```typescript
|
|
384
|
+
* mountAll('ui:accordion', ({ props }) => {
|
|
385
|
+
* const accordion = new Accordion(props);
|
|
386
|
+
* accordion.init();
|
|
387
|
+
* return accordion;
|
|
388
|
+
* });
|
|
389
|
+
* ```
|
|
390
|
+
*/
|
|
391
|
+
function mountAll(componentName, callback) {
|
|
392
|
+
const { namespace, baseName } = parseNamespacedComponentName(componentName);
|
|
393
|
+
const hydrationInstances = getHydrationData(componentName);
|
|
394
|
+
if (!hydrationInstances) return;
|
|
395
|
+
const clientBaseName = toKebabCase(baseName);
|
|
396
|
+
window.FluidPrimitives.componentInstances[namespace] ??= {};
|
|
397
|
+
window.FluidPrimitives.componentInstances[namespace][clientBaseName] ??= {};
|
|
398
|
+
const mountedInstances = window.FluidPrimitives.componentInstances[namespace][clientBaseName];
|
|
399
|
+
Object.keys(hydrationInstances).forEach((id) => {
|
|
400
|
+
if (hydrationInstances[id].autoMount === false) return;
|
|
401
|
+
if (mountedInstances[id]) return;
|
|
402
|
+
const instance = callback({
|
|
403
|
+
...hydrationInstances[id],
|
|
404
|
+
props: applyClientPropConverters(baseName, hydrationInstances[id].props),
|
|
405
|
+
createHydrator: () => new ComponentHydrator(baseName, id)
|
|
406
|
+
});
|
|
407
|
+
if (!instance) return;
|
|
408
|
+
instance.namespace = namespace;
|
|
409
|
+
mountedInstances[id] = instance;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Destroys every mounted instance whose root element is `root` or inside it, and forgets it. Call it
|
|
414
|
+
* before you remove a subtree from the DOM, e.g. a row of a FieldArray.
|
|
415
|
+
*
|
|
416
|
+
* Only the `root` part of an instance counts. A component without one, such as Dialog, is never
|
|
417
|
+
* matched.
|
|
418
|
+
*
|
|
419
|
+
* @param root - The element that is about to be removed.
|
|
420
|
+
*/
|
|
421
|
+
function destroyComponentsWithin(root) {
|
|
422
|
+
if (root instanceof Element) removePortalsWithin(root);
|
|
423
|
+
if (!window.FluidPrimitives) return;
|
|
424
|
+
for (const namespaceBucket of Object.values(window.FluidPrimitives.componentInstances)) for (const instances of Object.values(namespaceBucket)) for (const [id, instance] of Object.entries(instances)) {
|
|
425
|
+
const rootPartSelector = instance.hydrator.selector("root");
|
|
426
|
+
if (!rootPartSelector) continue;
|
|
427
|
+
const isRootItself = root instanceof Element && root.matches(rootPartSelector);
|
|
428
|
+
const hasRootPartInNode = root.querySelector(rootPartSelector) !== null;
|
|
429
|
+
if (isRootItself || hasRootPartInNode) {
|
|
430
|
+
console.log(`Destroying component instance: ${instance.getClientName()}:${id}`);
|
|
431
|
+
instance.destroy();
|
|
432
|
+
delete instances[id];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* Mounts one instance by its `rootId`, also one that was rendered with `autoMount="{false}"`. Unlike
|
|
438
|
+
* `mountAll()`, it creates a new instance on every call, which then replaces the one tracked before.
|
|
439
|
+
* `getComponentInstance()` and `destroyComponentsWithin()` find it like any other.
|
|
440
|
+
*
|
|
441
|
+
* The `props` in the callback are typed like those of `mountAll()`.
|
|
442
|
+
*
|
|
443
|
+
* @param componentName - The namespaced name, e.g. `ui:dialog`.
|
|
444
|
+
* @param rootId - The `rootId` the component was rendered with.
|
|
445
|
+
* @param callback - Creates the instance and returns it.
|
|
446
|
+
* @returns The instance, or `undefined` if there is no hydration entry for `rootId`.
|
|
447
|
+
*/
|
|
448
|
+
function mount(componentName, rootId, callback) {
|
|
449
|
+
const { namespace, baseName } = parseNamespacedComponentName(componentName);
|
|
450
|
+
const hydrationData = getHydrationData(componentName, rootId);
|
|
451
|
+
if (!hydrationData) return void 0;
|
|
452
|
+
const instance = callback({
|
|
453
|
+
...hydrationData,
|
|
454
|
+
props: applyClientPropConverters(baseName, hydrationData.props),
|
|
455
|
+
createHydrator: () => new ComponentHydrator(baseName, rootId)
|
|
456
|
+
});
|
|
457
|
+
if (!instance) return void 0;
|
|
458
|
+
instance.namespace = namespace;
|
|
459
|
+
const clientBaseName = toKebabCase(baseName);
|
|
460
|
+
window.FluidPrimitives.componentInstances[namespace] ??= {};
|
|
461
|
+
window.FluidPrimitives.componentInstances[namespace][clientBaseName] ??= {};
|
|
462
|
+
window.FluidPrimitives.componentInstances[namespace][clientBaseName][rootId] = instance;
|
|
463
|
+
return instance;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Finds the parts of one component instance. Every part that `ui:ref` renders carries a
|
|
467
|
+
* `data-<component>-<part>="<rootId>"` attribute, so a lookup needs no id and searches the whole
|
|
468
|
+
* document by default. That is also what finds content that was portaled away from the root.
|
|
469
|
+
*/
|
|
470
|
+
var ComponentHydrator = class {
|
|
471
|
+
/** The name of the component, in camelCase. */
|
|
472
|
+
componentName;
|
|
473
|
+
/** The kebab-case form of `componentName`, as it appears in attribute names. */
|
|
474
|
+
clientComponentName;
|
|
475
|
+
/** The document the parts are searched in. */
|
|
476
|
+
doc;
|
|
477
|
+
/** The `rootId` the component was rendered with. */
|
|
478
|
+
rootId;
|
|
479
|
+
/**
|
|
480
|
+
* @param componentName - The name of the component, in camelCase.
|
|
481
|
+
* @param rootId - The `rootId` the component was rendered with. Throws if it is missing.
|
|
482
|
+
* @param doc - The document the parts are searched in.
|
|
483
|
+
*/
|
|
484
|
+
constructor(componentName, rootId, doc = document) {
|
|
485
|
+
this.componentName = componentName;
|
|
486
|
+
this.clientComponentName = toKebabCase(componentName);
|
|
487
|
+
this.doc = doc;
|
|
488
|
+
if (!rootId) throw new Error(`Root ID is required for component hydration: ${componentName}`);
|
|
489
|
+
this.rootId = rootId;
|
|
490
|
+
}
|
|
491
|
+
get attrPrefix() {
|
|
492
|
+
return `data-${this.clientComponentName.replaceAll(".", "-")}-`;
|
|
493
|
+
}
|
|
494
|
+
/** The name of the attribute that marks `part`, e.g. `data-accordion-item-trigger`. */
|
|
495
|
+
attr(part) {
|
|
496
|
+
return this.attrPrefix + toKebabCase(part);
|
|
497
|
+
}
|
|
498
|
+
/** The CSS selector of every element of `part` that belongs to this instance. */
|
|
499
|
+
selector(part) {
|
|
500
|
+
return `[${this.attr(part)}="${this.rootId}"]`;
|
|
501
|
+
}
|
|
502
|
+
/** The key that identifies `part`, and optionally one `value` of it, across this instance. */
|
|
503
|
+
scopeKey(part, value) {
|
|
504
|
+
const key = `${this.clientComponentName}:${this.rootId}:${toKebabCase(part)}`;
|
|
505
|
+
return value ? `${key}:${value}` : key;
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Finds the first element of `part`.
|
|
509
|
+
*
|
|
510
|
+
* @param part - The name of the part, in camelCase.
|
|
511
|
+
* @param parent - Where to search, the whole document by default.
|
|
512
|
+
* @returns The element, or `null` if there is none.
|
|
513
|
+
*/
|
|
514
|
+
query(part, parent = this.doc) {
|
|
515
|
+
return parent.querySelector(this.selector(part));
|
|
516
|
+
}
|
|
517
|
+
/**
|
|
518
|
+
* Finds every element of `part`.
|
|
519
|
+
*
|
|
520
|
+
* @param part - The name of the part, in camelCase.
|
|
521
|
+
* @param parent - Where to search, the whole document by default.
|
|
522
|
+
*/
|
|
523
|
+
queryAll(part, parent = this.doc) {
|
|
524
|
+
return Array.from(parent.querySelectorAll(this.selector(part)));
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Marks an element as `part` of this instance, like `ui:ref` does on the server. For elements
|
|
528
|
+
* you create after hydration.
|
|
529
|
+
*
|
|
530
|
+
* @param element - The element to mark.
|
|
531
|
+
* @param part - The name of the part, in camelCase.
|
|
532
|
+
* @param value - Sets `data-value`, for a part that exists once per item.
|
|
533
|
+
*/
|
|
534
|
+
stamp(element, part, value) {
|
|
535
|
+
element.setAttribute(this.attr(part), this.rootId);
|
|
536
|
+
if (value !== void 0) element.setAttribute("data-value", value);
|
|
537
|
+
}
|
|
538
|
+
/** The part this component's own scope marks `el` as (kebab-case), if any. */
|
|
539
|
+
getOwnPart(el) {
|
|
540
|
+
for (const { name, value } of el.attributes) if (value === this.rootId && name.startsWith(this.attrPrefix)) return name.slice(this.attrPrefix.length);
|
|
541
|
+
return null;
|
|
542
|
+
}
|
|
543
|
+
/**
|
|
544
|
+
* Sets `data-value` on every element within `root` (root included) that is a part of *this*
|
|
545
|
+
* component's own scope, for a new, real `value` - the shared half of
|
|
546
|
+
* {@see restampValue}/{@see renameValue}, which differ only in what they each then do about any
|
|
547
|
+
* nested, independent root component `root` happens to compose (a fresh copy vs. an in-place
|
|
548
|
+
* rename - see `restampValue`'s own docblock for why the two are never auto-detected from one
|
|
549
|
+
* another).
|
|
550
|
+
*
|
|
551
|
+
* `attributes`, if given, are additionally set on the same elements `value` is applied to - for
|
|
552
|
+
* primitives whose `render()` reads plain per-element data attributes rather than resolving a
|
|
553
|
+
* collection item (e.g. RadioGroup's `disabled`/`invalid`, NavigationMenu's `Link` `current`).
|
|
554
|
+
* A boolean value toggles a bare `data-x` attribute (e.g. `{ disabled: true }` -> `data-disabled`,
|
|
555
|
+
* present only when true); a string value sets `data-x="value"` directly.
|
|
556
|
+
*/
|
|
557
|
+
restampOwnScope(root, value, attributes) {
|
|
558
|
+
const restamp = (el) => {
|
|
559
|
+
if (this.getOwnPart(el) === null) return;
|
|
560
|
+
el.setAttribute("data-value", value);
|
|
561
|
+
for (const [name, attributeValue] of Object.entries(attributes ?? {})) if (typeof attributeValue === "string") el.setAttribute(`data-${name}`, attributeValue);
|
|
562
|
+
else el.toggleAttribute(`data-${name}`, attributeValue);
|
|
563
|
+
};
|
|
564
|
+
restamp(root);
|
|
565
|
+
root.querySelectorAll("*").forEach(restamp);
|
|
566
|
+
}
|
|
567
|
+
/**
|
|
568
|
+
* @internal
|
|
569
|
+
*
|
|
570
|
+
* Re-stamps `root` for a new, real `value` (via {@see restampOwnScope}) and prepares any
|
|
571
|
+
* nested, *independent* root component found inside it (e.g. a `Field`+`Input` composed inside
|
|
572
|
+
* a `FieldArray` row, or any other primitive that composes another one inside its own per-item
|
|
573
|
+
* markup) as a brand-new instance copied from its own pristine stencil data, via
|
|
574
|
+
* {@see prepareNestedRootComponents}. Use after cloning a `<template>` (see `Template`) to make
|
|
575
|
+
* the clone represent one real item/row in a single call, instead of manually setting
|
|
576
|
+
* `data-value` on the root and separately on every nested value-scoped part (e.g. a combobox
|
|
577
|
+
* item's own `item-text`/`item-indicator`) *and* separately preparing whatever independent
|
|
578
|
+
* components that item happens to compose. Fully generic - not
|
|
579
|
+
* tied to any one primitive - so a `<template>` item "just works" regardless of what it
|
|
580
|
+
* contains, without the calling primitive needing to know or handle that itself.
|
|
581
|
+
*
|
|
582
|
+
* Only for a *freshly cloned* item - one that has never been through this or
|
|
583
|
+
* {@see renameValue} before (a `<template>`'s content is never mutated by a previous clone, so
|
|
584
|
+
* this is always true right after `content.cloneNode(true)`). For an *existing* item pulled
|
|
585
|
+
* from the live DOM whose value is merely changing (e.g. `FieldArray` reindexing a later row
|
|
586
|
+
* down by one after an earlier row was removed), call {@see renameValue} instead.
|
|
587
|
+
*
|
|
588
|
+
* The two used to be auto-detected from a nested component's own *current* id shape (no
|
|
589
|
+
* `:value` suffix yet vs. already has one) rather than asked for explicitly - but that shape
|
|
590
|
+
* can't actually tell a never-touched, already-live server-rendered row apart from a
|
|
591
|
+
* never-touched template clone; both look identical. That silently mis-classified an
|
|
592
|
+
* already-mounted row as "fresh" the first time an earlier row's removal caused it to be
|
|
593
|
+
* reindexed: its DOM references moved out from under it (via `restampIdReferences`, which both
|
|
594
|
+
* paths run) without its `ComponentHydrator.rootId`/`componentInstances` entry following along -
|
|
595
|
+
* only {@see renameValue}'s path updates those - leaving its already-mounted `Field`/`Input`
|
|
596
|
+
* instances silently unable to find their own elements again. The caller already knows
|
|
597
|
+
* unambiguously which case it's in (a fresh `Template` clone vs. an element already in the
|
|
598
|
+
* document), so it now says so by calling the matching method instead of leaving it to a guess.
|
|
599
|
+
*
|
|
600
|
+
* `stencilKey` is the stencil's own {@see scopeKey} (see `Template`'s constructor, its only
|
|
601
|
+
* caller) - what nested root components `root` composes is looked up from
|
|
602
|
+
* `window.FluidPrimitives.nestedComponents[stencilKey]` (PHP-recorded, see
|
|
603
|
+
* `HydrationRegistry::recordNestedComponent()`), not found by scanning `root` itself. That's
|
|
604
|
+
* what lets this find a nested component regardless of what DOM shape it renders - including
|
|
605
|
+
* one that renders no identifiable wrapper of its own at all (e.g. `Dialog`'s `Root`, which is
|
|
606
|
+
* just `<f:slot />` - see `ui:portal`'s own `isRenderStencil` handling for the other half of
|
|
607
|
+
* making a portalled nested component work inside a stencil).
|
|
608
|
+
*
|
|
609
|
+
* Returns the distinct client component names of any nested root components found and
|
|
610
|
+
* prepared (empty when `root` contains none, the common case - e.g. `FileUpload`'s own item
|
|
611
|
+
* previews) - call the matching `mountAll()`s for each of these after inserting a freshly
|
|
612
|
+
* cloned item into the document, per `mountAll`'s own docblock ("after lazily-inserted DOM
|
|
613
|
+
* adds new instances").
|
|
614
|
+
*/
|
|
615
|
+
restampValue(root, value, stencilKey, attributes) {
|
|
616
|
+
this.restampOwnScope(root, value, attributes);
|
|
617
|
+
const ownPart = this.getOwnPart(root);
|
|
618
|
+
return prepareNestedRootComponents(stencilKey, root, value, ownPart ? this.scopeKey(ownPart, value) : null);
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* @internal
|
|
622
|
+
*
|
|
623
|
+
* Re-stamps `root` for a new, real `value` (via {@see restampOwnScope}) and re-keys every
|
|
624
|
+
* nested, independent root component found inside it (e.g. a `FieldArray` row's own
|
|
625
|
+
* `Field`+`Input`) to reference `value` instead of whatever position it previously held - both
|
|
626
|
+
* their DOM references (via {@see restampIdReferences}) and, for ones that are already live/mounted,
|
|
627
|
+
* their own `ComponentHydrator.rootId` and their entry in
|
|
628
|
+
* `window.FluidPrimitives.componentInstances`/`hydrationData` (via
|
|
629
|
+
* {@see renameNestedComponentEntry}), so their future re-renders keep resolving their own
|
|
630
|
+
* elements and later lookups (`getComponentInstance`, `destroyComponentsWithin`) keep finding
|
|
631
|
+
* them under their new id.
|
|
632
|
+
*
|
|
633
|
+
* For an *existing* item pulled from the live DOM whose position is merely shifting - never for
|
|
634
|
+
* a freshly cloned `<template>` item, which has no existing instance/hydration-data entry to
|
|
635
|
+
* re-key in the first place; use {@see restampValue} for that. See that method's own docblock
|
|
636
|
+
* for why the two are never auto-detected from one another.
|
|
637
|
+
*
|
|
638
|
+
* Looks up what `root` composes from `window.FluidPrimitives.nestedComponents[<scopeKey>]`,
|
|
639
|
+
* keyed by `root`'s own part and `data-value` *before* this call restamps it, which is why
|
|
640
|
+
* that's read first, before {@see restampOwnScope} changes it. That's a correct lookup key both
|
|
641
|
+
* for a row rendered for real (PHP records it there too - see `ComponentHydrationCollector`'s
|
|
642
|
+
* FieldArray-ambient-context check) and for one `restampValue` prepared earlier (which migrates
|
|
643
|
+
* the same entry to the row's new key as its own last step, for exactly this reason).
|
|
644
|
+
*
|
|
645
|
+
* Restamps id *references* (via {@see restampIdReferences}) across the whole document, not just
|
|
646
|
+
* `root`'s own subtree - unlike {@see restampValue}, where a fresh clone can't yet have anything
|
|
647
|
+
* portaled out of it (nothing composed by it has mounted/opened yet), `root` here is an existing,
|
|
648
|
+
* already-live item, so a nested component it composes may already have portaled its own content
|
|
649
|
+
* elsewhere in the document (e.g. an open `Popover`'s positioner, rendered via `ui:portal` into
|
|
650
|
+
* the page footer) by the time this runs. Scoping to `root` alone would restamp that component's
|
|
651
|
+
* trigger in place but silently miss its portaled content, leaving it referencing a now-stale id.
|
|
652
|
+
*/
|
|
653
|
+
renameValue(root, value) {
|
|
654
|
+
const ownPart = this.getOwnPart(root);
|
|
655
|
+
const previousScopeKey = ownPart ? this.scopeKey(ownPart, root.getAttribute("data-value") ?? void 0) : null;
|
|
656
|
+
this.restampOwnScope(root, value);
|
|
657
|
+
if (!ownPart || !previousScopeKey) return;
|
|
658
|
+
const remaps = nestedRootRemapsFromMetadata(previousScopeKey, value);
|
|
659
|
+
if (remaps.length === 0) return;
|
|
660
|
+
restampIdReferences(this.doc, remaps);
|
|
661
|
+
for (const { componentName, oldRootId, newRootId } of remaps) renameNestedComponentEntry(componentName, oldRootId, newRootId, remaps);
|
|
662
|
+
migrateNestedComponentsScope(previousScopeKey, this.scopeKey(ownPart, value), remaps);
|
|
663
|
+
}
|
|
664
|
+
/** Nothing to release yet, it exists so every instance can be destroyed alike. */
|
|
665
|
+
destroy() {}
|
|
666
|
+
};
|
|
667
|
+
/**
|
|
668
|
+
* True when `id` references `rootId` as a whole id-segment - i.e. `id` either *is* `rootId`, or
|
|
669
|
+
* contains it immediately followed by `:` - not merely as a substring. A plain `.includes()`
|
|
670
|
+
* would false-positive match row `2`'s root id against row `20`'s (both contain the digit `2`).
|
|
671
|
+
*/
|
|
672
|
+
function referencesRootId(id, rootId) {
|
|
673
|
+
const index = id.indexOf(rootId);
|
|
674
|
+
if (index === -1) return false;
|
|
675
|
+
const charAfter = id[index + rootId.length];
|
|
676
|
+
return charAfter === void 0 || charAfter === ":";
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* Rewrites, on every element in `root` (root itself included), whatever references one of
|
|
680
|
+
* `remaps`' `oldRootId` so it references the matching `newRootId` instead: every part attribute of
|
|
681
|
+
* that nested component (`data-<nested>-<part>="oldRootId"`), and any real `id` that embeds it (a
|
|
682
|
+
* part given an explicit id through `ids`, see `ui:ref`). The id is rewritten via plain string
|
|
683
|
+
* substitution, not recomputed from a formula - an Input's `label`/`input` parts are server-stamped
|
|
684
|
+
* with their enclosing Field's own `label`/`control` ids (see `FieldIdMapping`), which an id
|
|
685
|
+
* derived from Input's own root id would silently lose. Scoped to `root` (the whole cloned item,
|
|
686
|
+
* not just one nested component's own subtree) since an override can reference a sibling
|
|
687
|
+
* component's id, not just an ancestor's - or, from {@see ComponentHydrator.renameValue}, to the
|
|
688
|
+
* whole document, when a nested component's content may already live outside `root` entirely (see
|
|
689
|
+
* that method's own docblock).
|
|
690
|
+
*/
|
|
691
|
+
function restampIdReferences(root, remaps) {
|
|
692
|
+
const restamp = (el) => {
|
|
693
|
+
for (const { name, value } of Array.from(el.attributes)) if (name === "id") {
|
|
694
|
+
const match = remaps.find(({ oldRootId }) => referencesRootId(value, oldRootId));
|
|
695
|
+
if (match) el.setAttribute(name, value.replace(match.oldRootId, match.newRootId));
|
|
696
|
+
} else if (name.startsWith("data-")) {
|
|
697
|
+
const match = remaps.find(({ oldRootId }) => oldRootId === value);
|
|
698
|
+
if (match) el.setAttribute(name, match.newRootId);
|
|
699
|
+
}
|
|
700
|
+
};
|
|
701
|
+
if (root instanceof Element) restamp(root);
|
|
702
|
+
root.querySelectorAll("*").forEach(restamp);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Remaps every value in a nested root component's `ids` prop override map using the same
|
|
706
|
+
* old->new root id substitutions {@see restampIdReferences} applied to the DOM, so the hydration
|
|
707
|
+
* props a later `mountAll()` constructs an instance from stay consistent with where its parts
|
|
708
|
+
* actually live post-restamp - without this, a component like Input (whose `ids.label`/`ids.input`
|
|
709
|
+
* may reference its enclosing Field's own ids, not its own) would keep looking for its label/
|
|
710
|
+
* input elements at their stale, pre-restamp ids and find nothing.
|
|
711
|
+
*/
|
|
712
|
+
function remapIdsProp(ids, remaps) {
|
|
713
|
+
const remapped = {};
|
|
714
|
+
for (const [key, value] of Object.entries(ids)) {
|
|
715
|
+
const match = remaps.find(({ oldRootId }) => referencesRootId(value, oldRootId));
|
|
716
|
+
remapped[key] = match ? value.replace(match.oldRootId, match.newRootId) : value;
|
|
717
|
+
}
|
|
718
|
+
return remapped;
|
|
719
|
+
}
|
|
720
|
+
/**
|
|
721
|
+
* Copies a stencil's cached hydration props to a freshly derived id, for a nested root component
|
|
722
|
+
* found in a freshly cloned `<template>` item (see {@see prepareNestedRootComponents}). Deep-clones
|
|
723
|
+
* the stencil's own props
|
|
724
|
+
* (registered once at server-render time, when that one stencil instance rendered) rather than
|
|
725
|
+
* moving them, since the stencil's own entry must stay put for the *next* clone to copy from too -
|
|
726
|
+
* unlike {@see renameNestedComponentEntry}, which re-keys a single existing entry in place.
|
|
727
|
+
*
|
|
728
|
+
* Rewrites a `[]` "append" placeholder in any string `name` prop to `value` (the same trailing-
|
|
729
|
+
* `[]`-means-append convention `form.path.ts`'s `appendFieldPathSegment`/`FieldContext.php` use).
|
|
730
|
+
* Returns whether a stencil entry was actually found and copied, so a caller only counts this
|
|
731
|
+
* component name as "found" when there was real hydration data to prepare.
|
|
732
|
+
*/
|
|
733
|
+
function copyNestedComponentEntry(componentName, stencilRootId, newRootId, remaps, value) {
|
|
734
|
+
const stencilData = getHydrationData(componentName, stencilRootId);
|
|
735
|
+
if (!stencilData) return false;
|
|
736
|
+
const rewrittenProps = {
|
|
737
|
+
...stencilData.props,
|
|
738
|
+
id: newRootId,
|
|
739
|
+
ids: remapIdsProp(stencilData.props.ids, remaps)
|
|
740
|
+
};
|
|
741
|
+
if (typeof rewrittenProps.name === "string" && rewrittenProps.name.includes("[]")) rewrittenProps.name = rewrittenProps.name.replace("[]", `[${value}]`);
|
|
742
|
+
const { namespace, baseName } = parseNamespacedComponentName(componentName);
|
|
743
|
+
const clientBaseName = toKebabCase(baseName);
|
|
744
|
+
window.FluidPrimitives.hydrationData[namespace] ??= {};
|
|
745
|
+
window.FluidPrimitives.hydrationData[namespace][clientBaseName] ??= {};
|
|
746
|
+
window.FluidPrimitives.hydrationData[namespace][clientBaseName][newRootId] = {
|
|
747
|
+
...stencilData,
|
|
748
|
+
props: rewrittenProps
|
|
749
|
+
};
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
/**
|
|
753
|
+
* Re-keys a single nested root component's own entry from `oldRootId` to `newRootId` in place -
|
|
754
|
+
* for an existing item pulled from the live DOM (see {@see ComponentHydrator.renameValue}), as
|
|
755
|
+
* opposed to a fresh one still in its pristine stencil state (see
|
|
756
|
+
* {@see copyNestedComponentEntry}). Updates both its `window.FluidPrimitives.
|
|
757
|
+
* hydrationData` entry and, for one that's already live/mounted, its own `ComponentHydrator.
|
|
758
|
+
* rootId` and its entry in `window.FluidPrimitives.componentInstances`, so its future
|
|
759
|
+
* re-renders keep resolving their own elements correctly and later lookups
|
|
760
|
+
* (`getComponentInstance`, `destroyComponentsWithin`) keep finding it under its new id.
|
|
761
|
+
*/
|
|
762
|
+
function renameNestedComponentEntry(componentName, oldRootId, newRootId, remaps) {
|
|
763
|
+
const { namespace, baseName } = parseNamespacedComponentName(componentName);
|
|
764
|
+
const clientBaseName = toKebabCase(baseName);
|
|
765
|
+
const instances = window.FluidPrimitives?.componentInstances?.[namespace]?.[clientBaseName];
|
|
766
|
+
const instance = instances?.[oldRootId];
|
|
767
|
+
if (instance) {
|
|
768
|
+
delete instances[oldRootId];
|
|
769
|
+
instances[newRootId] = instance;
|
|
770
|
+
instance.hydrator.rootId = newRootId;
|
|
771
|
+
}
|
|
772
|
+
const hydrationInstances = window.FluidPrimitives?.hydrationData?.[namespace]?.[clientBaseName];
|
|
773
|
+
const stencilData = hydrationInstances?.[oldRootId];
|
|
774
|
+
if (hydrationInstances && stencilData) {
|
|
775
|
+
delete hydrationInstances[oldRootId];
|
|
776
|
+
hydrationInstances[newRootId] = {
|
|
777
|
+
...stencilData,
|
|
778
|
+
props: {
|
|
779
|
+
...stencilData.props,
|
|
780
|
+
id: newRootId,
|
|
781
|
+
ids: remapIdsProp(stencilData.props.ids, remaps)
|
|
782
|
+
}
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Builds the `value`-suffixed id each root component PHP recorded as nested inside `scopeKey`
|
|
788
|
+
* (see {@see getNestedComponents}) should move to - the metadata itself, not this formula, is what
|
|
789
|
+
* tells the two apart from any *other* root component on the page.
|
|
790
|
+
*
|
|
791
|
+
* Recovers each one's stable, stencil-rooted prefix from its *current* recorded id first -
|
|
792
|
+
* stripping back to before the last `:` segment, if it has one - so a component already renamed
|
|
793
|
+
* once (`x:0` -> `x:1`) has that suffix *replaced* rather than accumulated (`x:0:1`). A component
|
|
794
|
+
* that's never been touched at all (fresh from either a never-rendered-before-now server row or an
|
|
795
|
+
* untouched `<template>` stencil) has no `:` in its rootId to strip, so this is a correct no-op for
|
|
796
|
+
* it - its whole current id already is the stable prefix.
|
|
797
|
+
*
|
|
798
|
+
* Shared by both {@see prepareNestedRootComponents} (fresh `<template>` clones) and
|
|
799
|
+
* {@see ComponentHydrator.renameValue} (existing items whose position is shifting) - the remap
|
|
800
|
+
* shape itself doesn't differ between the two; only what each one does with it afterward (copy a
|
|
801
|
+
* stencil's props to the new id vs. re-key an existing entry in place) does.
|
|
802
|
+
*/
|
|
803
|
+
function nestedRootRemapsFromMetadata(scopeKey, value) {
|
|
804
|
+
return getNestedComponents(scopeKey).map(({ name, id: currentRootId }) => {
|
|
805
|
+
const lastColonIndex = currentRootId.lastIndexOf(":");
|
|
806
|
+
return {
|
|
807
|
+
componentName: name,
|
|
808
|
+
oldRootId: currentRootId,
|
|
809
|
+
newRootId: `${lastColonIndex === -1 ? currentRootId : currentRootId.slice(0, lastColonIndex)}:${value}`
|
|
810
|
+
};
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
/**
|
|
814
|
+
* Records a copy of `window.FluidPrimitives.nestedComponents[oldScopeKey]` (if present) under
|
|
815
|
+
* `newScopeKey`, rewriting each entry's own `id` through the same `remaps`
|
|
816
|
+
* {@see nestedRootRemapsFromMetadata} just computed from it - so a *later*
|
|
817
|
+
* {@see ComponentHydrator.renameValue} on this same item (a second reindex) still finds it under
|
|
818
|
+
* the id the item actually has by then, rather than the one it was first recorded under. Leaves
|
|
819
|
+
* `oldScopeKey` in place: for a fresh clone that is the stencil, which the *next* clone needs too.
|
|
820
|
+
* Returns whether there was anything to copy.
|
|
821
|
+
*/
|
|
822
|
+
function copyNestedComponentsScope(oldScopeKey, newScopeKey, remaps) {
|
|
823
|
+
const entries = window.FluidPrimitives?.nestedComponents?.[oldScopeKey];
|
|
824
|
+
if (!entries || oldScopeKey === newScopeKey) return false;
|
|
825
|
+
window.FluidPrimitives.nestedComponents ??= {};
|
|
826
|
+
window.FluidPrimitives.nestedComponents[newScopeKey] = entries.map((entry) => {
|
|
827
|
+
const match = remaps.find((remap) => remap.componentName === entry.name && remap.oldRootId === entry.id);
|
|
828
|
+
return match ? {
|
|
829
|
+
name: entry.name,
|
|
830
|
+
id: match.newRootId
|
|
831
|
+
} : entry;
|
|
832
|
+
});
|
|
833
|
+
return true;
|
|
834
|
+
}
|
|
835
|
+
/**
|
|
836
|
+
* {@see copyNestedComponentsScope}, then drops `oldScopeKey` - for an existing item whose own key
|
|
837
|
+
* is changing (see {@see ComponentHydrator.renameValue}), as opposed to a fresh clone.
|
|
838
|
+
*/
|
|
839
|
+
function migrateNestedComponentsScope(oldScopeKey, newScopeKey, remaps) {
|
|
840
|
+
if (copyNestedComponentsScope(oldScopeKey, newScopeKey, remaps)) delete window.FluidPrimitives.nestedComponents?.[oldScopeKey];
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Prepares every nested, independent root component inside a freshly cloned `<template>` item for
|
|
844
|
+
* `value` - the fresh-clone half of {@see ComponentHydrator.restampValue}, which this backs. Every
|
|
845
|
+
* element that *references* one of those root ids - not just the root element itself - is
|
|
846
|
+
* re-stamped too, via {@see restampIdReferences} (which also fixes up any part whose id references
|
|
847
|
+
* a *different* nested component's own id, e.g. Input's label/input reusing Field's), plus the
|
|
848
|
+
* same substitution applied to each one's own `ids` prop override map via {@see remapIdsProp}.
|
|
849
|
+
* Without this, a nested component's parts would keep the root id of the stencil they were cloned
|
|
850
|
+
* from and stay attached to it, since they're outside `restampValue`'s own scope (which only
|
|
851
|
+
* covers the *outer* component's own parts).
|
|
852
|
+
*
|
|
853
|
+
* Copies each one's stencil hydration data (registered once at server-render time, when that one
|
|
854
|
+
* stencil instance rendered) to the new id via {@see copyNestedComponentEntry}, so a subsequent
|
|
855
|
+
* `mountAll()` can construct it. Deliberately does not construct component instances itself -
|
|
856
|
+
* there is no registry mapping a componentName to its constructor anywhere in this library (each
|
|
857
|
+
* primitive's own `mountAll('field', callback)` call, with its own constructor, is defined
|
|
858
|
+
* per-consumer in their own entry.ts) - see {@see ComponentHydrator.restampValue}'s own docblock
|
|
859
|
+
* for what to do with the client component names this returns instead.
|
|
860
|
+
*/
|
|
861
|
+
function prepareNestedRootComponents(stencilKey, root, value, newScopeKey) {
|
|
862
|
+
const remaps = nestedRootRemapsFromMetadata(stencilKey, value);
|
|
863
|
+
if (remaps.length === 0) return [];
|
|
864
|
+
restampIdReferences(root, remaps);
|
|
865
|
+
const componentNames = /* @__PURE__ */ new Set();
|
|
866
|
+
for (const { componentName, oldRootId, newRootId } of remaps) if (copyNestedComponentEntry(componentName, oldRootId, newRootId, remaps, value)) componentNames.add(componentName);
|
|
867
|
+
if (newScopeKey) copyNestedComponentsScope(stencilKey, newScopeKey, remaps);
|
|
868
|
+
return Array.from(componentNames);
|
|
869
|
+
}
|
|
870
|
+
function getByPath(item, path) {
|
|
871
|
+
return path.split(".").reduce((value, key) => value?.[key], item);
|
|
872
|
+
}
|
|
873
|
+
function getListCollectionFromHydrationData(hydrationCollection) {
|
|
874
|
+
if (hydrationCollection instanceof ListCollection) return hydrationCollection;
|
|
875
|
+
const { itemToValueKey, itemToStringKey, isItemDisabledKey, groupByKey, groupSort } = hydrationCollection;
|
|
876
|
+
return new ListCollection({
|
|
877
|
+
items: hydrationCollection.items,
|
|
878
|
+
itemToValue: itemToValueKey ? (item) => getByPath(item, itemToValueKey) : void 0,
|
|
879
|
+
itemToString: itemToStringKey ? (item) => getByPath(item, itemToStringKey) : void 0,
|
|
880
|
+
isItemDisabled: isItemDisabledKey ? (item) => getByPath(item, isItemDisabledKey) : void 0,
|
|
881
|
+
groupBy: groupByKey ? (item) => getByPath(item, groupByKey) : void 0,
|
|
882
|
+
groupSort: groupSort ?? void 0
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
//#endregion
|
|
887
|
+
//#region Resources/Private/Client/src/lib/client-prop-converters.ts
|
|
888
|
+
const converters = /* @__PURE__ */ new Map();
|
|
889
|
+
/**
|
|
890
|
+
* Registers which props of a component need converting before a `mountAll()` or `mount()` callback
|
|
891
|
+
* receives them, e.g. the `collection` of a Select: JSON on the wire, a `ListCollection` once
|
|
892
|
+
* converted.
|
|
893
|
+
*
|
|
894
|
+
* @param componentName - The component, e.g. `select`.
|
|
895
|
+
* @param propConverters - The converters, by prop name.
|
|
896
|
+
*/
|
|
897
|
+
function registerClientPropConverters(componentName, propConverters) {
|
|
898
|
+
converters.set(toKebabCase(componentName), propConverters);
|
|
899
|
+
}
|
|
900
|
+
/**
|
|
901
|
+
* @internal
|
|
902
|
+
*
|
|
903
|
+
* Runs every registered converter for `componentName` against `props`, called for every
|
|
904
|
+
* registered key regardless of whether it's actually present on `props` - a converter is
|
|
905
|
+
* registered per prop *name*, not per wire value, so it can supply a default for a prop the wire
|
|
906
|
+
* data omitted entirely (e.g. Combobox's `collection` when a purely async, searchUrl-only combobox
|
|
907
|
+
* sends none).
|
|
908
|
+
*/
|
|
909
|
+
function applyClientPropConverters(componentName, props) {
|
|
910
|
+
const componentConverters = converters.get(toKebabCase(componentName));
|
|
911
|
+
if (!componentConverters) return props;
|
|
912
|
+
const result = { ...props };
|
|
913
|
+
for (const [propName, convert] of Object.entries(componentConverters)) result[propName] = convert(result[propName], result);
|
|
914
|
+
return result;
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region Resources/Private/Client/src/lib/delayed-indicator.ts
|
|
919
|
+
const DEFAULT_SHOW_DELAY_MS = 150;
|
|
920
|
+
const DEFAULT_MIN_VISIBLE_MS = 300;
|
|
921
|
+
function assertFiniteNonNegative(value, name) {
|
|
922
|
+
if (!Number.isFinite(value) || value < 0) throw new RangeError(`DelayedIndicator: ${name} must be a finite number >= 0, got ${value}.`);
|
|
923
|
+
return value;
|
|
924
|
+
}
|
|
925
|
+
/**
|
|
926
|
+
* Delays showing a transient value long enough to avoid flicker, and keeps it visible for a minimum
|
|
927
|
+
* duration once shown. Feed it every new value through `set()`, typically straight from a subscribe
|
|
928
|
+
* callback, and it forwards them to `onChange`. The values `isTransient` marks are held back for
|
|
929
|
+
* `showDelayMs`, so a fast operation never flashes them. Once shown they stay for at least
|
|
930
|
+
* `minVisibleMs`, so they can't vanish the instant they appeared.
|
|
931
|
+
*
|
|
932
|
+
* It works on any value, not only a boolean: one instance can drive a spinner and a status text
|
|
933
|
+
* from the same source, so they never disagree.
|
|
934
|
+
*/
|
|
935
|
+
var DelayedIndicator = class {
|
|
936
|
+
showDelayMs;
|
|
937
|
+
minVisibleMs;
|
|
938
|
+
isTransient;
|
|
939
|
+
onChange;
|
|
940
|
+
phase = "idle";
|
|
941
|
+
latestValue;
|
|
942
|
+
timer = null;
|
|
943
|
+
shownAt = 0;
|
|
944
|
+
constructor(options) {
|
|
945
|
+
this.showDelayMs = assertFiniteNonNegative(options.showDelayMs ?? DEFAULT_SHOW_DELAY_MS, "showDelayMs");
|
|
946
|
+
this.minVisibleMs = assertFiniteNonNegative(options.minVisibleMs ?? DEFAULT_MIN_VISIBLE_MS, "minVisibleMs");
|
|
947
|
+
this.isTransient = options.isTransient;
|
|
948
|
+
this.onChange = options.onChange;
|
|
949
|
+
}
|
|
950
|
+
/**
|
|
951
|
+
* Passes a new value on, delayed as described above. It does not deduplicate: the same value
|
|
952
|
+
* again can still call `onChange` again, as this filters state over time, it is not a
|
|
953
|
+
* `distinctUntilChanged()`.
|
|
954
|
+
*
|
|
955
|
+
* @param value - The new value.
|
|
956
|
+
*/
|
|
957
|
+
set(value) {
|
|
958
|
+
this.latestValue = value;
|
|
959
|
+
const transient = this.isTransient(value);
|
|
960
|
+
if (this.phase === "idle") {
|
|
961
|
+
if (!transient) {
|
|
962
|
+
this.onChange(value);
|
|
963
|
+
return;
|
|
964
|
+
}
|
|
965
|
+
this.phase = "waiting";
|
|
966
|
+
this.timer = setTimeout(() => {
|
|
967
|
+
this.timer = null;
|
|
968
|
+
this.phase = "shown";
|
|
969
|
+
this.shownAt = performance.now();
|
|
970
|
+
this.onChange(this.latestValue);
|
|
971
|
+
}, this.showDelayMs);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
if (this.phase === "waiting") {
|
|
975
|
+
if (transient) return;
|
|
976
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
977
|
+
this.timer = null;
|
|
978
|
+
this.phase = "idle";
|
|
979
|
+
this.onChange(value);
|
|
980
|
+
return;
|
|
981
|
+
}
|
|
982
|
+
if (transient) {
|
|
983
|
+
if (this.timer !== null) {
|
|
984
|
+
clearTimeout(this.timer);
|
|
985
|
+
this.timer = null;
|
|
986
|
+
}
|
|
987
|
+
this.onChange(value);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (this.timer !== null) return;
|
|
991
|
+
const remaining = this.minVisibleMs - (performance.now() - this.shownAt);
|
|
992
|
+
const commit = () => {
|
|
993
|
+
this.timer = null;
|
|
994
|
+
this.phase = "idle";
|
|
995
|
+
this.onChange(this.latestValue);
|
|
996
|
+
};
|
|
997
|
+
if (remaining > 0) this.timer = setTimeout(commit, remaining);
|
|
998
|
+
else commit();
|
|
999
|
+
}
|
|
1000
|
+
/**
|
|
1001
|
+
* Cancels any timer that is running. Call it when the indicator is no longer needed, e.g. on
|
|
1002
|
+
* teardown: otherwise a scheduled value can still reach `onChange` afterwards. It is safe to call
|
|
1003
|
+
* when nothing is transient or shown.
|
|
1004
|
+
*/
|
|
1005
|
+
destroy() {
|
|
1006
|
+
if (this.timer !== null) {
|
|
1007
|
+
clearTimeout(this.timer);
|
|
1008
|
+
this.timer = null;
|
|
1009
|
+
}
|
|
1010
|
+
this.phase = "idle";
|
|
1011
|
+
}
|
|
1012
|
+
};
|
|
1013
|
+
|
|
1014
|
+
//#endregion
|
|
1015
|
+
//#region Resources/Private/Client/src/lib/extbase.ts
|
|
1016
|
+
/**
|
|
1017
|
+
* Reads the Extbase argument namespace (`tx_docs_docs`) of a frontend action URL, from its
|
|
1018
|
+
* `[controller]` or `[action]` query key. `UriBuilder::uriFor()` nests those under it, also for a
|
|
1019
|
+
* custom `pluginNamespace`.
|
|
1020
|
+
*
|
|
1021
|
+
* @param url - The URL of the action.
|
|
1022
|
+
* @returns The namespace, or `null` for a URL without such a key.
|
|
1023
|
+
*/
|
|
1024
|
+
function getArgumentPrefix(url) {
|
|
1025
|
+
for (const key of new URL(url, document.baseURI).searchParams.keys()) {
|
|
1026
|
+
const prefix = key.match(/^([^[\]]+)\[(?:action|controller)\]$/)?.[1];
|
|
1027
|
+
if (prefix) return prefix;
|
|
1028
|
+
}
|
|
1029
|
+
return null;
|
|
1030
|
+
}
|
|
1031
|
+
/** Bracket notation (`demand[city]`, `ids[0]`) is what Extbase's argument mapper reads. */
|
|
1032
|
+
function flatten(value, key) {
|
|
1033
|
+
if (value == null) return [];
|
|
1034
|
+
if (Array.isArray(value) || value.constructor === Object) return Object.entries(value).flatMap(([k, v]) => flatten(v, key ? `${key}[${k}]` : k));
|
|
1035
|
+
return [[key, value instanceof Blob ? value : String(value)]];
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Sends GET and POST requests to an Extbase controller action from your own code, e.g. from the
|
|
1039
|
+
* `load` callback of an async search. The data is nested under the Extbase argument namespace of the
|
|
1040
|
+
* URL, the way `f:uri.action` builds it.
|
|
1041
|
+
*/
|
|
1042
|
+
const extbase = {
|
|
1043
|
+
/**
|
|
1044
|
+
* GETs `url` with `data` appended as query parameters under its Extbase argument namespace. The
|
|
1045
|
+
* URL's own query, `cHash` included, is kept - so the new parameters have to be listed in
|
|
1046
|
+
* `FE.cacheHash.excludedParameters`, or TYPO3 rejects the request.
|
|
1047
|
+
*
|
|
1048
|
+
* @param url - The URL of the action, absolute or relative.
|
|
1049
|
+
* @param data - The arguments. Nested objects and arrays are flattened into bracket notation.
|
|
1050
|
+
* @param init - More options for `fetch`, e.g. `{ signal }`.
|
|
1051
|
+
* @returns The native `Response`. A non-2xx status resolves normally, check `response.ok`.
|
|
1052
|
+
*/
|
|
1053
|
+
async get(url, data, init) {
|
|
1054
|
+
const target = new URL(url, document.baseURI);
|
|
1055
|
+
for (const [key, value] of flatten(data, getArgumentPrefix(url) ?? "")) target.searchParams.append(key, String(value));
|
|
1056
|
+
return fetch(target, {
|
|
1057
|
+
...init,
|
|
1058
|
+
method: "GET"
|
|
1059
|
+
});
|
|
1060
|
+
},
|
|
1061
|
+
/**
|
|
1062
|
+
* POSTs `data` as `multipart/form-data`, nested under the URL's Extbase argument namespace. A
|
|
1063
|
+
* URL without one gets `data` unprefixed.
|
|
1064
|
+
*
|
|
1065
|
+
* @param url - The URL of the action.
|
|
1066
|
+
* @param data - The arguments. Nested objects and arrays are flattened into bracket notation, `null`
|
|
1067
|
+
* and `undefined` are skipped, a `File` or `Blob` is sent as it is.
|
|
1068
|
+
* @param init - More options for `fetch`, e.g. `{ signal }`.
|
|
1069
|
+
* @returns The native `Response`. A non-2xx status resolves normally, check `response.ok`.
|
|
1070
|
+
*/
|
|
1071
|
+
async post(url, data, init) {
|
|
1072
|
+
const body = new FormData();
|
|
1073
|
+
for (const [key, value] of flatten(data, getArgumentPrefix(url) ?? "")) body.append(key, value);
|
|
1074
|
+
return fetch(url, {
|
|
1075
|
+
...init,
|
|
1076
|
+
method: "POST",
|
|
1077
|
+
body
|
|
1078
|
+
});
|
|
1079
|
+
},
|
|
1080
|
+
getArgumentPrefix
|
|
1081
|
+
};
|
|
1082
|
+
|
|
1083
|
+
//#endregion
|
|
1084
|
+
//#region Resources/Private/Primitives/Form/src/form.path.ts
|
|
1085
|
+
/** Matches one path segment: either a bare run of non-delimiter chars, or bracket contents. */
|
|
1086
|
+
const fieldPathSegmentPattern = /([^.[\]]+)|\[(.*?)\]/g;
|
|
1087
|
+
/** Sentinel for an empty `[]` segment - "append to this array" - distinct from any real field name. */
|
|
1088
|
+
const appendFieldPathSegment = Symbol("append-field-path-segment");
|
|
1089
|
+
/** Strips a trailing `[]` (the manual-bracket array-submission marker, e.g. `a11yNeeds[]`) if present. */
|
|
1090
|
+
function trimArraySuffix(fieldName) {
|
|
1091
|
+
return fieldName.replace(/\[\]$/, "");
|
|
1092
|
+
}
|
|
1093
|
+
/**
|
|
1094
|
+
* Splits a field name into its path segments, treating `.` and `[...]` as equivalent
|
|
1095
|
+
* delimiters - `person.country` and `person[country]` both parse to `['person', 'country']`.
|
|
1096
|
+
* An all-digit segment becomes a number, and an empty `[]` becomes {@link appendFieldPathSegment}.
|
|
1097
|
+
*/
|
|
1098
|
+
function parseFieldPath(fieldName) {
|
|
1099
|
+
const fieldPath = [];
|
|
1100
|
+
for (const match of fieldName.matchAll(fieldPathSegmentPattern)) {
|
|
1101
|
+
if (match[1]) {
|
|
1102
|
+
const segment = match[1];
|
|
1103
|
+
fieldPath.push(/^\d+$/.test(segment) ? Number(segment) : segment);
|
|
1104
|
+
continue;
|
|
1105
|
+
}
|
|
1106
|
+
const bracketSegment = match[2] ?? "";
|
|
1107
|
+
if (bracketSegment === "") {
|
|
1108
|
+
fieldPath.push(appendFieldPathSegment);
|
|
1109
|
+
continue;
|
|
1110
|
+
}
|
|
1111
|
+
fieldPath.push(/^\d+$/.test(bracketSegment) ? Number(bracketSegment) : bracketSegment);
|
|
1112
|
+
}
|
|
1113
|
+
return fieldPath;
|
|
1114
|
+
}
|
|
1115
|
+
/**
|
|
1116
|
+
* Joins path segments back into one bracketed name: the first segment stays bare, every
|
|
1117
|
+
* segment after it gets wrapped in `[...]`, and {@link appendFieldPathSegment} becomes `[]`.
|
|
1118
|
+
*/
|
|
1119
|
+
function stringifyFieldPathAsBrackets(fieldPath) {
|
|
1120
|
+
let fieldName = "";
|
|
1121
|
+
for (const segment of fieldPath) {
|
|
1122
|
+
if (segment === appendFieldPathSegment) {
|
|
1123
|
+
fieldName += "[]";
|
|
1124
|
+
continue;
|
|
1125
|
+
}
|
|
1126
|
+
const segmentValue = String(segment);
|
|
1127
|
+
fieldName += fieldName === "" ? segmentValue : `[${segmentValue}]`;
|
|
1128
|
+
}
|
|
1129
|
+
return fieldName;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Parses `fieldName` (dot or bracket notation) and rebuilds it fully bracketed, with
|
|
1133
|
+
* `prefix` and `objectName` unshifted onto the front in that order. Used to rewrite submitted
|
|
1134
|
+
* `FormData` keys into the form's real Extbase argument name just before `fetch()`.
|
|
1135
|
+
*/
|
|
1136
|
+
function prefixFieldName(fieldName, prefix, objectName) {
|
|
1137
|
+
if (fieldName === "") return "";
|
|
1138
|
+
const fieldPath = parseFieldPath(fieldName);
|
|
1139
|
+
if (objectName) fieldPath.unshift(objectName);
|
|
1140
|
+
if (prefix) fieldPath.unshift(prefix);
|
|
1141
|
+
return stringifyFieldPathAsBrackets(fieldPath);
|
|
1142
|
+
}
|
|
1143
|
+
/**
|
|
1144
|
+
* Resolves any field-name string (dot or bracket, optionally objectName-prefixed) to the exact
|
|
1145
|
+
* bracket-notation key the field registry uses.
|
|
1146
|
+
*/
|
|
1147
|
+
function toRegisteredFieldName(fieldName, objectName) {
|
|
1148
|
+
const fieldPath = parseFieldPath(fieldName);
|
|
1149
|
+
if (fieldPath.length === 0) return trimArraySuffix(fieldName);
|
|
1150
|
+
if (objectName && fieldPath[0] === objectName) fieldPath.shift();
|
|
1151
|
+
return trimArraySuffix(stringifyFieldPathAsBrackets(fieldPath));
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
//#endregion
|
|
1155
|
+
//#region Resources/Private/Primitives/Form/src/form.registry.ts
|
|
1156
|
+
const registry$1 = /* @__PURE__ */ new WeakMap();
|
|
1157
|
+
function registerFormMachine(form, service) {
|
|
1158
|
+
if (!form) return;
|
|
1159
|
+
registry$1.set(form, {
|
|
1160
|
+
machine: service,
|
|
1161
|
+
fields: /* @__PURE__ */ new Map(),
|
|
1162
|
+
expectedFieldCount: form.querySelectorAll("[data-field-root]").length || 0
|
|
1163
|
+
});
|
|
1164
|
+
form.dispatchEvent(new CustomEvent("fluid-primitives:form:registered", { bubbles: true }));
|
|
1165
|
+
}
|
|
1166
|
+
function registerFieldMachineForForm(el, fieldMachine) {
|
|
1167
|
+
if (!el) return;
|
|
1168
|
+
const form = resolveElToForm(el);
|
|
1169
|
+
if (!form) return;
|
|
1170
|
+
const handleEntry = (entry) => {
|
|
1171
|
+
entry.fields.set(trimArraySuffix(fieldMachine.prop("name")), fieldMachine);
|
|
1172
|
+
if (entry.fields.size === entry.expectedFieldCount) entry.machine.notify();
|
|
1173
|
+
else if (entry.fields.size > entry.expectedFieldCount) entry.machine.notify?.();
|
|
1174
|
+
};
|
|
1175
|
+
const handler = () => {
|
|
1176
|
+
const entry = registry$1.get(form);
|
|
1177
|
+
if (!entry) return;
|
|
1178
|
+
handleEntry(entry);
|
|
1179
|
+
form.removeEventListener("fluid-primitives:form:registered", handler);
|
|
1180
|
+
};
|
|
1181
|
+
const entry = registry$1.get(form);
|
|
1182
|
+
if (entry) handleEntry(entry);
|
|
1183
|
+
else form.addEventListener("fluid-primitives:form:registered", handler);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Drops a field's entry by identity rather than by name: the entry may have been re-keyed by a
|
|
1187
|
+
* rename, and a destroyed field's stopped machine must not keep counting as a field of the form.
|
|
1188
|
+
*/
|
|
1189
|
+
function unregisterFieldMachineForForm(el, fieldMachine) {
|
|
1190
|
+
const fields = registry$1.get(resolveElToForm(el))?.fields;
|
|
1191
|
+
if (!fields) return;
|
|
1192
|
+
for (const [name, registered] of fields) if (registered === fieldMachine) fields.delete(name);
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* Re-keys a registered field machine under a new field name and updates its
|
|
1196
|
+
* `name` prop, without touching its value/touched/dirty/error state. This is
|
|
1197
|
+
* how recurring-field rows stay contiguously indexed after removing a row in
|
|
1198
|
+
* the middle: later rows are renamed down by one instead of copying values
|
|
1199
|
+
* between DOM elements, which keeps working regardless of what kind of
|
|
1200
|
+
* control (native input, Select, NumberInput, ...) the field wraps.
|
|
1201
|
+
*/
|
|
1202
|
+
function renameFieldMachineForForm(el, oldName, newName) {
|
|
1203
|
+
const form = resolveElToForm(el);
|
|
1204
|
+
if (!form) return;
|
|
1205
|
+
const entry = registry$1.get(form);
|
|
1206
|
+
if (!entry) return;
|
|
1207
|
+
const normalizedOldName = trimArraySuffix(oldName);
|
|
1208
|
+
const fieldMachine = entry.fields.get(normalizedOldName);
|
|
1209
|
+
if (!fieldMachine) return;
|
|
1210
|
+
entry.fields.delete(normalizedOldName);
|
|
1211
|
+
fieldMachine.updateProps({ name: newName });
|
|
1212
|
+
entry.fields.set(trimArraySuffix(newName), fieldMachine);
|
|
1213
|
+
}
|
|
1214
|
+
function resolveElToForm(el) {
|
|
1215
|
+
if (!el) return null;
|
|
1216
|
+
if (el instanceof HTMLFormElement) return el;
|
|
1217
|
+
return el.closest("form");
|
|
1218
|
+
}
|
|
1219
|
+
function getFormMachineFor(el) {
|
|
1220
|
+
if (!el) return;
|
|
1221
|
+
const form = resolveElToForm(el);
|
|
1222
|
+
if (!form) return;
|
|
1223
|
+
return registry$1.get(form)?.machine;
|
|
1224
|
+
}
|
|
1225
|
+
function getFieldMachinesFor(el) {
|
|
1226
|
+
if (!el) return /* @__PURE__ */ new Map();
|
|
1227
|
+
const form = resolveElToForm(el);
|
|
1228
|
+
if (!form) return /* @__PURE__ */ new Map();
|
|
1229
|
+
const entry = registry$1.get(form);
|
|
1230
|
+
if (!entry) return /* @__PURE__ */ new Map();
|
|
1231
|
+
return entry.fields;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
//#endregion
|
|
1235
|
+
//#region Resources/Private/Primitives/Field/src/field.anatomy.ts
|
|
1236
|
+
/**
|
|
1237
|
+
* Derived from @zag-js/field of Zag.js (https://github.com/chakra-ui/zag, packages/machines/field,
|
|
1238
|
+
* commit 0ce8e3de63b571d37e438bcf28e64759466e56e9), MIT License, Copyright (c) 2021 Chakra UI.
|
|
1239
|
+
* Adapted for Fluid Primitives, the git history shows what changed from the original.
|
|
1240
|
+
*/
|
|
1241
|
+
const anatomy = createAnatomy("field").parts("root", "label", "control", "helperText", "errorText", "indicator");
|
|
1242
|
+
const parts = anatomy.build();
|
|
1243
|
+
|
|
1244
|
+
//#endregion
|
|
1245
|
+
//#region Resources/Private/Primitives/Field/src/field.dom.ts
|
|
1246
|
+
/** Dispatched on the field root once its settled value differs from the last one it announced. */
|
|
1247
|
+
const FIELD_VALUE_CHANGE_EVENT = "fluid-primitives:field:valuechange";
|
|
1248
|
+
const getRootId = (ctx) => ctx.ids?.root ?? ctx.id;
|
|
1249
|
+
const getControlId = (ctx) => ctx.ids?.control ?? `field:${ctx.id}:control`;
|
|
1250
|
+
const getLabelId = (ctx) => ctx.ids?.label ?? `field:${ctx.id}:label`;
|
|
1251
|
+
const getErrorTextId = (ctx, match, id) => {
|
|
1252
|
+
if (id) return id;
|
|
1253
|
+
if (typeof match === "string") return `field:${ctx.id}:error-text:${match}`;
|
|
1254
|
+
return ctx.ids?.errorText ?? `field:${ctx.id}:error-text`;
|
|
1255
|
+
};
|
|
1256
|
+
const getHelperTextId = (ctx) => ctx.ids?.helperText ?? `field:${ctx.id}:helper-text`;
|
|
1257
|
+
const queryRootEl = (ctx) => ctx.query(ctx.selector(parts.root));
|
|
1258
|
+
const getControlEl = (ctx) => ctx.getById(getControlId(ctx));
|
|
1259
|
+
const hasHelperText = (rootEl) => rootEl.querySelector(`[${parts.helperText.attr}]`) !== null;
|
|
1260
|
+
const getVisibleErrorTextIds = (rootEl) => Array.from(rootEl.querySelectorAll(`[${parts.errorText.attr}]`)).filter((el) => !el.hidden && el.id).map((el) => el.id);
|
|
1261
|
+
const componentRootAttribute = /^(data-.+-)root$/;
|
|
1262
|
+
/**
|
|
1263
|
+
* Whether `target` belongs to the field: inside its root, or a portaled part (a popup, say) of a
|
|
1264
|
+
* component rendered inside it. Those parts sit outside the root but carry
|
|
1265
|
+
* `data-<component>-<part>="<id of that component's root>"`, like the root itself.
|
|
1266
|
+
*/
|
|
1267
|
+
function isInsideField(rootEl, target) {
|
|
1268
|
+
if (!(target instanceof Element)) return false;
|
|
1269
|
+
if (rootEl.contains(target)) return true;
|
|
1270
|
+
const prefixById = /* @__PURE__ */ new Map();
|
|
1271
|
+
for (const el of rootEl.querySelectorAll("*")) for (const { name, value } of Array.from(el.attributes)) {
|
|
1272
|
+
const match = componentRootAttribute.exec(name);
|
|
1273
|
+
if (match && value) prefixById.set(value, match[1]);
|
|
1274
|
+
}
|
|
1275
|
+
if (prefixById.size === 0) return false;
|
|
1276
|
+
for (let el = target; el; el = el.parentElement) for (const { name, value } of Array.from(el.attributes)) {
|
|
1277
|
+
const prefix = prefixById.get(value);
|
|
1278
|
+
if (prefix && name.startsWith(prefix)) return true;
|
|
1279
|
+
}
|
|
1280
|
+
return false;
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
//#endregion
|
|
1284
|
+
//#region Resources/Private/Primitives/Field/src/field.utils.ts
|
|
1285
|
+
const VALIDITY_KEYS = [
|
|
1286
|
+
"badInput",
|
|
1287
|
+
"customError",
|
|
1288
|
+
"patternMismatch",
|
|
1289
|
+
"rangeOverflow",
|
|
1290
|
+
"rangeUnderflow",
|
|
1291
|
+
"stepMismatch",
|
|
1292
|
+
"tooLong",
|
|
1293
|
+
"tooShort",
|
|
1294
|
+
"typeMismatch",
|
|
1295
|
+
"valueMissing"
|
|
1296
|
+
];
|
|
1297
|
+
const VALID_SNAPSHOT = Object.freeze({
|
|
1298
|
+
badInput: false,
|
|
1299
|
+
customError: false,
|
|
1300
|
+
patternMismatch: false,
|
|
1301
|
+
rangeOverflow: false,
|
|
1302
|
+
rangeUnderflow: false,
|
|
1303
|
+
stepMismatch: false,
|
|
1304
|
+
tooLong: false,
|
|
1305
|
+
tooShort: false,
|
|
1306
|
+
typeMismatch: false,
|
|
1307
|
+
valueMissing: false,
|
|
1308
|
+
valid: true
|
|
1309
|
+
});
|
|
1310
|
+
/** Copy the live `ValidityState` — its getters are recomputed on every read. */
|
|
1311
|
+
function getValiditySnapshot(el) {
|
|
1312
|
+
const snapshot = {};
|
|
1313
|
+
for (const key of VALIDITY_KEYS) snapshot[key] = el.validity[key];
|
|
1314
|
+
snapshot.valid = el.validity.valid;
|
|
1315
|
+
return snapshot;
|
|
1316
|
+
}
|
|
1317
|
+
/**
|
|
1318
|
+
* A pristine empty required field is not an error yet. When `valueMissing` is the
|
|
1319
|
+
* only failure, report the field as valid until the user interacts or a submit forces it.
|
|
1320
|
+
*/
|
|
1321
|
+
function suppressValueMissing(validity) {
|
|
1322
|
+
if (!validity.valueMissing) return validity;
|
|
1323
|
+
if (!VALIDITY_KEYS.every((key) => key === "valueMissing" || !validity[key])) return validity;
|
|
1324
|
+
return {
|
|
1325
|
+
...validity,
|
|
1326
|
+
valueMissing: false,
|
|
1327
|
+
valid: true
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* The native flags with `valueMissing` replaced. The field's own `required` and its value decide it,
|
|
1332
|
+
* not the control's `required` attribute: a Combobox's visible input holds label text, a group has no
|
|
1333
|
+
* control at all, and a placeholder option is always selected.
|
|
1334
|
+
*/
|
|
1335
|
+
function withValueMissing(validity, valueMissing) {
|
|
1336
|
+
const next = {
|
|
1337
|
+
...validity,
|
|
1338
|
+
valueMissing
|
|
1339
|
+
};
|
|
1340
|
+
next.valid = VALIDITY_KEYS.every((key) => !next[key]);
|
|
1341
|
+
return next;
|
|
1342
|
+
}
|
|
1343
|
+
function toErrorArray(result) {
|
|
1344
|
+
if (result == null) return [];
|
|
1345
|
+
return (Array.isArray(result) ? result : [result]).filter((error) => typeof error === "string" && error.length > 0);
|
|
1346
|
+
}
|
|
1347
|
+
/**
|
|
1348
|
+
* Priority: custom `validate` errors outrank native constraint errors.
|
|
1349
|
+
* (The controlled `invalid` prop outranks both, at the computed level.)
|
|
1350
|
+
*/
|
|
1351
|
+
function resolveValidation(options) {
|
|
1352
|
+
const { customErrors, validity, nativeMessage } = options;
|
|
1353
|
+
if (customErrors.length > 0) return {
|
|
1354
|
+
errors: customErrors,
|
|
1355
|
+
validity: {
|
|
1356
|
+
...validity,
|
|
1357
|
+
customError: true,
|
|
1358
|
+
valid: false
|
|
1359
|
+
}
|
|
1360
|
+
};
|
|
1361
|
+
if (!validity.valid) return {
|
|
1362
|
+
errors: nativeMessage ? [nativeMessage] : [],
|
|
1363
|
+
validity
|
|
1364
|
+
};
|
|
1365
|
+
return {
|
|
1366
|
+
errors: [],
|
|
1367
|
+
validity
|
|
1368
|
+
};
|
|
1369
|
+
}
|
|
1370
|
+
/**
|
|
1371
|
+
* Description first, then visible error ids in DOM order.
|
|
1372
|
+
* Hidden error texts must not be referenced — screen readers still announce them.
|
|
1373
|
+
*/
|
|
1374
|
+
function composeDescribedBy(options) {
|
|
1375
|
+
const ids = [];
|
|
1376
|
+
if (options.hasHelperText) ids.push(options.helperTextId);
|
|
1377
|
+
ids.push(...options.errorTextIds);
|
|
1378
|
+
return ids.length > 0 ? ids.join(" ") : void 0;
|
|
1379
|
+
}
|
|
1380
|
+
/**
|
|
1381
|
+
* Whether this event should commit validation (make it visible).
|
|
1382
|
+
* `VALIDATE` / `SUBMIT.INVALID` always commit and do not go through this.
|
|
1383
|
+
*
|
|
1384
|
+
* Two rules go beyond the modes: an error that is showing revalidates on every change in every
|
|
1385
|
+
* mode, so it clears the moment it is fixed; and a blur only commits once the value was edited or a
|
|
1386
|
+
* submit was attempted, so tabbing through a pristine field, or a popup taking focus from its
|
|
1387
|
+
* trigger, shows nothing.
|
|
1388
|
+
*/
|
|
1389
|
+
function shouldCommit(options) {
|
|
1390
|
+
const { mode, submitAttempted, eventType, edited, showingErrors } = options;
|
|
1391
|
+
if (eventType === "CONTROL.BLUR") return mode === "onBlur" && (edited || submitAttempted);
|
|
1392
|
+
if (eventType === "CONTROL.CHANGE") return mode === "onChange" || mode === "onSubmit" && submitAttempted || showingErrors;
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
/** Whether the error text should show for the given `match`. */
|
|
1396
|
+
function isErrorMatch(match, options) {
|
|
1397
|
+
const { validity, invalid, disabled } = options;
|
|
1398
|
+
if (match === true) return true;
|
|
1399
|
+
if (match === false || disabled) return false;
|
|
1400
|
+
if (typeof match === "string") return validity?.[match] === true;
|
|
1401
|
+
return invalid;
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
//#endregion
|
|
1405
|
+
//#region Resources/Private/Primitives/Field/src/field.connect.ts
|
|
1406
|
+
function connect(service, normalize) {
|
|
1407
|
+
const { send, context, prop, computed, scope } = service;
|
|
1408
|
+
const disabled = computed("disabled");
|
|
1409
|
+
const invalid = computed("invalid");
|
|
1410
|
+
const valid = computed("valid");
|
|
1411
|
+
const required = !!prop("required");
|
|
1412
|
+
const readOnly = !!prop("readOnly");
|
|
1413
|
+
const focused = !disabled && context.get("focused");
|
|
1414
|
+
const touched = context.get("touched");
|
|
1415
|
+
const dirty = context.get("dirty");
|
|
1416
|
+
const filled = context.get("filled");
|
|
1417
|
+
const validating = context.get("validating");
|
|
1418
|
+
const ids = {
|
|
1419
|
+
root: getRootId(scope),
|
|
1420
|
+
control: getControlId(scope),
|
|
1421
|
+
label: getLabelId(scope),
|
|
1422
|
+
errorText: getErrorTextId(scope),
|
|
1423
|
+
helperText: getHelperTextId(scope)
|
|
1424
|
+
};
|
|
1425
|
+
const ariaDescribedby = composeDescribedBy({
|
|
1426
|
+
helperTextId: ids.helperText,
|
|
1427
|
+
hasHelperText: context.get("hasHelperText"),
|
|
1428
|
+
errorTextIds: context.get("errorTextIds")
|
|
1429
|
+
});
|
|
1430
|
+
function getFieldState() {
|
|
1431
|
+
return {
|
|
1432
|
+
disabled,
|
|
1433
|
+
invalid,
|
|
1434
|
+
valid,
|
|
1435
|
+
required,
|
|
1436
|
+
readOnly,
|
|
1437
|
+
touched,
|
|
1438
|
+
dirty,
|
|
1439
|
+
filled,
|
|
1440
|
+
focused,
|
|
1441
|
+
validating
|
|
1442
|
+
};
|
|
1443
|
+
}
|
|
1444
|
+
function getErrorTextState(props = {}) {
|
|
1445
|
+
const shown = isErrorMatch(props.match, {
|
|
1446
|
+
validity: context.get("validity"),
|
|
1447
|
+
invalid,
|
|
1448
|
+
disabled
|
|
1449
|
+
});
|
|
1450
|
+
return {
|
|
1451
|
+
...getFieldState(),
|
|
1452
|
+
hidden: !shown
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
function getIndicatorState(props) {
|
|
1456
|
+
const fieldState = getFieldState();
|
|
1457
|
+
const shown = match(props.type, {
|
|
1458
|
+
required: () => fieldState.required,
|
|
1459
|
+
invalid: () => fieldState.invalid,
|
|
1460
|
+
valid: () => fieldState.valid === true,
|
|
1461
|
+
validating: () => fieldState.validating
|
|
1462
|
+
});
|
|
1463
|
+
return {
|
|
1464
|
+
...fieldState,
|
|
1465
|
+
type: props.type,
|
|
1466
|
+
hidden: !shown
|
|
1467
|
+
};
|
|
1468
|
+
}
|
|
1469
|
+
function getControlBaseProps() {
|
|
1470
|
+
const fieldState = getFieldState();
|
|
1471
|
+
return {
|
|
1472
|
+
...parts.control.attrs(scope.id),
|
|
1473
|
+
id: ids.control,
|
|
1474
|
+
name: prop("name"),
|
|
1475
|
+
dir: prop("dir"),
|
|
1476
|
+
disabled: fieldState.disabled,
|
|
1477
|
+
required: fieldState.required,
|
|
1478
|
+
"aria-invalid": fieldState.invalid || void 0,
|
|
1479
|
+
"aria-describedby": ariaDescribedby,
|
|
1480
|
+
...getDataAttrs(fieldState)
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
return {
|
|
1484
|
+
ids,
|
|
1485
|
+
disabled,
|
|
1486
|
+
invalid,
|
|
1487
|
+
valid,
|
|
1488
|
+
required,
|
|
1489
|
+
readOnly,
|
|
1490
|
+
focused,
|
|
1491
|
+
touched,
|
|
1492
|
+
dirty,
|
|
1493
|
+
filled,
|
|
1494
|
+
validating,
|
|
1495
|
+
errors: context.get("errors"),
|
|
1496
|
+
validity: context.get("validity"),
|
|
1497
|
+
ariaDescribedby,
|
|
1498
|
+
validate() {
|
|
1499
|
+
send({ type: "VALIDATE" });
|
|
1500
|
+
},
|
|
1501
|
+
clearErrors() {
|
|
1502
|
+
send({ type: "ERRORS.CLEAR" });
|
|
1503
|
+
},
|
|
1504
|
+
reset() {
|
|
1505
|
+
send({ type: "RESET" });
|
|
1506
|
+
},
|
|
1507
|
+
getRootState: getFieldState,
|
|
1508
|
+
getRootProps() {
|
|
1509
|
+
return normalize.element({
|
|
1510
|
+
...parts.root.attrs(scope.id),
|
|
1511
|
+
dir: prop("dir"),
|
|
1512
|
+
/** The name of the field. */
|
|
1513
|
+
"data-name": prop("name"),
|
|
1514
|
+
...getDataAttrs(getFieldState())
|
|
1515
|
+
});
|
|
1516
|
+
},
|
|
1517
|
+
getLabelState: getFieldState,
|
|
1518
|
+
getLabelProps() {
|
|
1519
|
+
return normalize.label({
|
|
1520
|
+
...parts.label.attrs(scope.id),
|
|
1521
|
+
id: ids.label,
|
|
1522
|
+
dir: prop("dir"),
|
|
1523
|
+
htmlFor: ids.control,
|
|
1524
|
+
...getDataAttrs(getFieldState())
|
|
1525
|
+
});
|
|
1526
|
+
},
|
|
1527
|
+
getControlState: getFieldState,
|
|
1528
|
+
getControlProps() {
|
|
1529
|
+
return normalize.element({ ...getControlBaseProps() });
|
|
1530
|
+
},
|
|
1531
|
+
getInputProps() {
|
|
1532
|
+
return normalize.input({
|
|
1533
|
+
...getControlBaseProps(),
|
|
1534
|
+
readOnly: getFieldState().readOnly
|
|
1535
|
+
});
|
|
1536
|
+
},
|
|
1537
|
+
getTextareaProps() {
|
|
1538
|
+
return normalize.textarea({
|
|
1539
|
+
...getControlBaseProps(),
|
|
1540
|
+
readOnly: getFieldState().readOnly
|
|
1541
|
+
});
|
|
1542
|
+
},
|
|
1543
|
+
getSelectProps() {
|
|
1544
|
+
return normalize.select({ ...getControlBaseProps() });
|
|
1545
|
+
},
|
|
1546
|
+
getHelperTextState: getFieldState,
|
|
1547
|
+
getHelperTextProps() {
|
|
1548
|
+
return normalize.element({
|
|
1549
|
+
...parts.helperText.attrs(scope.id),
|
|
1550
|
+
id: ids.helperText,
|
|
1551
|
+
dir: prop("dir"),
|
|
1552
|
+
...getDataAttrs(getFieldState())
|
|
1553
|
+
});
|
|
1554
|
+
},
|
|
1555
|
+
getErrorTextState,
|
|
1556
|
+
getErrorTextProps(props = {}) {
|
|
1557
|
+
const errorTextState = getErrorTextState(props);
|
|
1558
|
+
return normalize.element({
|
|
1559
|
+
...parts.errorText.attrs(scope.id),
|
|
1560
|
+
id: getErrorTextId(scope, props.match, props.id),
|
|
1561
|
+
dir: prop("dir"),
|
|
1562
|
+
hidden: errorTextState.hidden,
|
|
1563
|
+
"aria-live": "polite",
|
|
1564
|
+
...getDataAttrs(errorTextState)
|
|
1565
|
+
});
|
|
1566
|
+
},
|
|
1567
|
+
getIndicatorState,
|
|
1568
|
+
getIndicatorProps(props) {
|
|
1569
|
+
const indicatorState = getIndicatorState(props);
|
|
1570
|
+
return normalize.element({
|
|
1571
|
+
...parts.indicator.attrs(scope.id),
|
|
1572
|
+
dir: prop("dir"),
|
|
1573
|
+
/** The state the indicator reflects. */
|
|
1574
|
+
"data-type": indicatorState.type,
|
|
1575
|
+
"aria-hidden": true,
|
|
1576
|
+
hidden: indicatorState.hidden,
|
|
1577
|
+
...getDataAttrs(indicatorState)
|
|
1578
|
+
});
|
|
1579
|
+
}
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
function getDataAttrs(state) {
|
|
1583
|
+
return {
|
|
1584
|
+
/** Present when the field is disabled, or it is inside a disabled fieldset. */
|
|
1585
|
+
"data-disabled": dataAttr(state.disabled),
|
|
1586
|
+
/** Present when the field is invalid: a native constraint failed, or `validate`, the form or the server reported an error. */
|
|
1587
|
+
"data-invalid": dataAttr(state.invalid),
|
|
1588
|
+
/** Present when the field has been validated and passed. */
|
|
1589
|
+
"data-valid": dataAttr(state.valid === true),
|
|
1590
|
+
/** Present when the field is required. */
|
|
1591
|
+
"data-required": dataAttr(state.required),
|
|
1592
|
+
/** Present when the field is read-only. */
|
|
1593
|
+
"data-readonly": dataAttr(state.readOnly),
|
|
1594
|
+
/** Present once the user has left the field at least once. */
|
|
1595
|
+
"data-touched": dataAttr(state.touched),
|
|
1596
|
+
/** Present when the value differs from the one the field started with. It goes away again when the user reverts the edit. */
|
|
1597
|
+
"data-dirty": dataAttr(state.dirty),
|
|
1598
|
+
/** Present when the field has a value. */
|
|
1599
|
+
"data-filled": dataAttr(state.filled),
|
|
1600
|
+
/** Present when focus is inside the field. */
|
|
1601
|
+
"data-focus": dataAttr(state.focused)
|
|
1602
|
+
};
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
//#endregion
|
|
1606
|
+
//#region Resources/Private/Primitives/Field/src/field.value.ts
|
|
1607
|
+
function getCurrentFieldValue(rootEl, name, defaultValue) {
|
|
1608
|
+
if (!rootEl) return getDefaultFieldValue(defaultValue);
|
|
1609
|
+
return getFieldValueFromContainer(rootEl, name) ?? getDefaultFieldValue(defaultValue);
|
|
1610
|
+
}
|
|
1611
|
+
/**
|
|
1612
|
+
* The field value as one string, which is what the machine compares and tests for emptiness:
|
|
1613
|
+
* `''` for nothing (`null`, `[]`, or only the `''` entries a placeholder option leaves behind), a
|
|
1614
|
+
* plain string as is - not trimmed, native `valueMissing` treats whitespace as a value - and a
|
|
1615
|
+
* serialization for lists and files.
|
|
1616
|
+
*/
|
|
1617
|
+
function toComparableString(value) {
|
|
1618
|
+
if (value === null) return "";
|
|
1619
|
+
if (typeof value === "string") return value;
|
|
1620
|
+
if (Array.isArray(value)) {
|
|
1621
|
+
const entries = value.filter((entry) => entry !== "");
|
|
1622
|
+
return entries.length > 0 ? serializeFieldValue(entries) : "";
|
|
1623
|
+
}
|
|
1624
|
+
return serializeFieldValue(value);
|
|
1625
|
+
}
|
|
1626
|
+
function getComparableFieldValue(rootEl, name, defaultValue) {
|
|
1627
|
+
return toComparableString(getCurrentFieldValue(rootEl, name, defaultValue));
|
|
1628
|
+
}
|
|
1629
|
+
function getDefaultFieldValue(defaultValue) {
|
|
1630
|
+
if (defaultValue === null || defaultValue === void 0 || defaultValue === false) return null;
|
|
1631
|
+
if (defaultValue instanceof File) return defaultValue;
|
|
1632
|
+
if (Array.isArray(defaultValue)) return defaultValue.filter((value) => value instanceof File || typeof value === "string");
|
|
1633
|
+
if (typeof defaultValue === "string") return defaultValue;
|
|
1634
|
+
return null;
|
|
1635
|
+
}
|
|
1636
|
+
function getFieldValueFromContainer(container, fieldName) {
|
|
1637
|
+
const normalizedFieldName = trimArraySuffix(fieldName);
|
|
1638
|
+
const selector = [
|
|
1639
|
+
`input[name="${CSS.escape(normalizedFieldName)}"]`,
|
|
1640
|
+
`input[name="${CSS.escape(normalizedFieldName)}[]"]`,
|
|
1641
|
+
`select[name="${CSS.escape(normalizedFieldName)}"]`,
|
|
1642
|
+
`select[name="${CSS.escape(normalizedFieldName)}[]"]`,
|
|
1643
|
+
`textarea[name="${CSS.escape(normalizedFieldName)}"]`,
|
|
1644
|
+
`textarea[name="${CSS.escape(normalizedFieldName)}[]"]`
|
|
1645
|
+
].join(", ");
|
|
1646
|
+
const elements = Array.from(container.querySelectorAll(selector));
|
|
1647
|
+
if (elements.length === 0) return null;
|
|
1648
|
+
if (elements.every((el) => el instanceof HTMLInputElement && el.type === "checkbox")) {
|
|
1649
|
+
const checkboxes = elements;
|
|
1650
|
+
const checkedValues = checkboxes.filter((input) => input.checked).map((input) => input.value || "on");
|
|
1651
|
+
if (fieldName.endsWith("[]") || elements.some((el) => el.name.endsWith("[]")) || checkboxes.length > 1) return checkedValues;
|
|
1652
|
+
return checkedValues[0] ?? null;
|
|
1653
|
+
}
|
|
1654
|
+
if (elements.every((el) => el instanceof HTMLInputElement && el.type === "radio")) return elements.find((input) => input.checked)?.value ?? null;
|
|
1655
|
+
if (elements.length === 1) {
|
|
1656
|
+
const [element] = elements;
|
|
1657
|
+
if (element instanceof HTMLInputElement && element.type === "file") {
|
|
1658
|
+
const files = element.files ? Array.from(element.files) : [];
|
|
1659
|
+
return element.multiple ? files : files[0] ?? null;
|
|
1660
|
+
}
|
|
1661
|
+
if (element instanceof HTMLSelectElement && element.multiple) return Array.from(element.selectedOptions).map((option) => option.value);
|
|
1662
|
+
return element.value ?? null;
|
|
1663
|
+
}
|
|
1664
|
+
return elements.map((element) => {
|
|
1665
|
+
if (element instanceof HTMLInputElement && element.type === "file") return element.files?.[0];
|
|
1666
|
+
return element.value;
|
|
1667
|
+
}).filter((value) => value !== void 0);
|
|
1668
|
+
}
|
|
1669
|
+
function getFieldElement(form, fieldName) {
|
|
1670
|
+
return form.querySelector([
|
|
1671
|
+
`input[name="${CSS.escape(fieldName)}"]`,
|
|
1672
|
+
`input[name="${CSS.escape(fieldName)}[]"]`,
|
|
1673
|
+
`select[name="${CSS.escape(fieldName)}"]`,
|
|
1674
|
+
`select[name="${CSS.escape(fieldName)}[]"]`,
|
|
1675
|
+
`textarea[name="${CSS.escape(fieldName)}"]`,
|
|
1676
|
+
`textarea[name="${CSS.escape(fieldName)}[]"]`
|
|
1677
|
+
].join(", "));
|
|
1678
|
+
}
|
|
1679
|
+
function serializeFieldValue(value) {
|
|
1680
|
+
return JSON.stringify(toSerializableFieldValue(value));
|
|
1681
|
+
}
|
|
1682
|
+
function toSerializableFieldValue(value) {
|
|
1683
|
+
if (Array.isArray(value)) return value.map(toSerializableFieldValue);
|
|
1684
|
+
if (value instanceof File) return {
|
|
1685
|
+
name: value.name,
|
|
1686
|
+
size: value.size,
|
|
1687
|
+
type: value.type,
|
|
1688
|
+
lastModified: value.lastModified
|
|
1689
|
+
};
|
|
1690
|
+
return value;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
//#endregion
|
|
1694
|
+
//#region Resources/Private/Primitives/Field/src/field.handle.ts
|
|
1695
|
+
/** Dispatched on a field's root whenever a field named in its own `listenTo` changes value. */
|
|
1696
|
+
const FIELD_DEPENDENCY_CHANGE_EVENT = "fluid-primitives:field:dependencychange";
|
|
1697
|
+
function connectField(machine) {
|
|
1698
|
+
return {
|
|
1699
|
+
...connect(machine.service, normalizeProps),
|
|
1700
|
+
...createFieldHandleExtras(machine)
|
|
1701
|
+
};
|
|
1702
|
+
}
|
|
1703
|
+
function createFieldHandleExtras(machine) {
|
|
1704
|
+
const { context, prop, refs } = machine;
|
|
1705
|
+
return {
|
|
1706
|
+
name: prop("name"),
|
|
1707
|
+
getValue: () => getCurrentFieldValue(refs.get("rootEl"), prop("name"), prop("defaultValue")),
|
|
1708
|
+
getErrorText() {
|
|
1709
|
+
const errors = context.get("errors");
|
|
1710
|
+
return errors.length > 0 ? errors.join(" ") : null;
|
|
1711
|
+
},
|
|
1712
|
+
getRootEl: () => refs.get("rootEl"),
|
|
1713
|
+
getFormMachine: () => getFormMachineFor(refs.get("rootEl")),
|
|
1714
|
+
setDisabled: (disabled) => machine.updateProps({ disabled }),
|
|
1715
|
+
setRequired: (required) => machine.updateProps({ required }),
|
|
1716
|
+
setReadOnly: (readOnly) => machine.updateProps({ readOnly }),
|
|
1717
|
+
addDependencyChangeListener(callback) {
|
|
1718
|
+
const rootEl = refs.get("rootEl");
|
|
1719
|
+
if (!rootEl) return () => {};
|
|
1720
|
+
const handler = (event) => {
|
|
1721
|
+
callback(event.detail);
|
|
1722
|
+
};
|
|
1723
|
+
rootEl.addEventListener(FIELD_DEPENDENCY_CHANGE_EVENT, handler);
|
|
1724
|
+
return () => rootEl.removeEventListener(FIELD_DEPENDENCY_CHANGE_EVENT, handler);
|
|
1725
|
+
}
|
|
1726
|
+
};
|
|
1727
|
+
}
|
|
1728
|
+
/** Reads the machine directly instead of building the whole api: the Form makes one per field per render. */
|
|
1729
|
+
function createFieldHandle(machine) {
|
|
1730
|
+
const { context, computed, prop } = machine;
|
|
1731
|
+
const disabled = computed("disabled");
|
|
1732
|
+
return {
|
|
1733
|
+
disabled,
|
|
1734
|
+
required: !!prop("required"),
|
|
1735
|
+
readOnly: !!prop("readOnly"),
|
|
1736
|
+
invalid: computed("invalid"),
|
|
1737
|
+
valid: computed("valid"),
|
|
1738
|
+
touched: context.get("touched"),
|
|
1739
|
+
dirty: context.get("dirty"),
|
|
1740
|
+
filled: context.get("filled"),
|
|
1741
|
+
focused: !disabled && context.get("focused"),
|
|
1742
|
+
validating: context.get("validating"),
|
|
1743
|
+
errors: context.get("errors"),
|
|
1744
|
+
validate: () => machine.send({ type: "VALIDATE" }),
|
|
1745
|
+
clearErrors: () => machine.send({ type: "ERRORS.CLEAR" }),
|
|
1746
|
+
reset: () => machine.send({ type: "RESET" }),
|
|
1747
|
+
...createFieldHandleExtras(machine)
|
|
1748
|
+
};
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
//#endregion
|
|
1752
|
+
//#region Resources/Private/Primitives/Field/src/field.registry.ts
|
|
1753
|
+
const registry = /* @__PURE__ */ new WeakMap();
|
|
1754
|
+
function registerFieldMachine(field, service) {
|
|
1755
|
+
if (!field) return;
|
|
1756
|
+
registry.set(field, service);
|
|
1757
|
+
field.dispatchEvent(new CustomEvent("fluid-primitives:field:registered", { bubbles: true }));
|
|
1758
|
+
}
|
|
1759
|
+
function unregisterFieldMachine(field) {
|
|
1760
|
+
registry.delete(field);
|
|
1761
|
+
}
|
|
1762
|
+
function getFieldMachineFor(el) {
|
|
1763
|
+
if (!el) return;
|
|
1764
|
+
return registry.get(el);
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
//#endregion
|
|
1768
|
+
//#region Resources/Private/Client/src/lib/field-aware-component.ts
|
|
1769
|
+
const fieldProps = [...[
|
|
1770
|
+
"invalid",
|
|
1771
|
+
"disabled",
|
|
1772
|
+
"readOnly",
|
|
1773
|
+
"required"
|
|
1774
|
+
], "name"];
|
|
1775
|
+
/**
|
|
1776
|
+
* Base class of a primitive that can sit inside a Field. It takes `name`, `disabled`, `readOnly`,
|
|
1777
|
+
* `required` and `invalid` from the closest Field and follows its changes, and it tells the field
|
|
1778
|
+
* once it has hydrated.
|
|
1779
|
+
*/
|
|
1780
|
+
var FieldAwareComponent = class extends Component {
|
|
1781
|
+
subscribedToField = false;
|
|
1782
|
+
fieldMachine;
|
|
1783
|
+
closestField = null;
|
|
1784
|
+
/** The field's current api, for what a primitive merges into its own props when it renders. */
|
|
1785
|
+
get field() {
|
|
1786
|
+
return this.fieldMachine ? connectField(this.fieldMachine) : void 0;
|
|
1787
|
+
}
|
|
1788
|
+
getClosestField() {
|
|
1789
|
+
return this.closestField || this.hydrator.query("root")?.closest("[data-field-root]") || null;
|
|
1790
|
+
}
|
|
1791
|
+
/** Builds the component like `Component.init()`, then tells the field that it is hydrated. */
|
|
1792
|
+
init() {
|
|
1793
|
+
super.init();
|
|
1794
|
+
this.notifyFieldHydrated();
|
|
1795
|
+
}
|
|
1796
|
+
withFieldProps(props) {
|
|
1797
|
+
this.closestField = this.getClosestField();
|
|
1798
|
+
if (!this.closestField) return props;
|
|
1799
|
+
this.fieldMachine = getFieldMachineFor(this.closestField);
|
|
1800
|
+
if (this.fieldMachine) return this.propsWithField(props, connectField(this.fieldMachine));
|
|
1801
|
+
else {
|
|
1802
|
+
const handler = () => {
|
|
1803
|
+
this.fieldMachine = getFieldMachineFor(this.closestField);
|
|
1804
|
+
this.updateProps(this.propsWithField(this.userProps, connectField(this.fieldMachine)));
|
|
1805
|
+
this.notifyFieldHydrated();
|
|
1806
|
+
this.closestField?.removeEventListener("fluid-primitives:field:registered", handler);
|
|
1807
|
+
};
|
|
1808
|
+
this.closestField.addEventListener("fluid-primitives:field:registered", handler);
|
|
1809
|
+
}
|
|
1810
|
+
return props;
|
|
1811
|
+
}
|
|
1812
|
+
/**
|
|
1813
|
+
* Applies every later change of the field to the machine props. It waits for the field if that
|
|
1814
|
+
* has not registered yet, and does nothing when there is no field around.
|
|
1815
|
+
*/
|
|
1816
|
+
subscribeToFieldService() {
|
|
1817
|
+
if (this.subscribedToField) return;
|
|
1818
|
+
this.closestField = this.getClosestField();
|
|
1819
|
+
if (!this.closestField) return;
|
|
1820
|
+
if (!this.fieldMachine) this.fieldMachine = getFieldMachineFor(this.closestField);
|
|
1821
|
+
if (this.fieldMachine) {
|
|
1822
|
+
const fieldMachine = this.fieldMachine;
|
|
1823
|
+
fieldMachine.subscribe(() => {
|
|
1824
|
+
queueMicrotask(() => {
|
|
1825
|
+
const field = connectField(fieldMachine);
|
|
1826
|
+
const propsToUpdate = {};
|
|
1827
|
+
for (const prop of fieldProps) {
|
|
1828
|
+
const newValue = prop === "name" ? field.name : !!field[prop];
|
|
1829
|
+
if (newValue !== (prop === "name" ? this.machine.prop(prop) : !!this.machine.prop(prop))) propsToUpdate[prop] = newValue;
|
|
1830
|
+
}
|
|
1831
|
+
if (Object.keys(propsToUpdate).length > 0) this.updateProps(propsToUpdate);
|
|
1832
|
+
else this.machine.notify();
|
|
1833
|
+
});
|
|
1834
|
+
});
|
|
1835
|
+
this.subscribedToField = true;
|
|
1836
|
+
} else {
|
|
1837
|
+
const handler = () => {
|
|
1838
|
+
this.subscribeToFieldService();
|
|
1839
|
+
this.closestField.removeEventListener("fluid-primitives:field:registered", handler);
|
|
1840
|
+
};
|
|
1841
|
+
this.closestField.addEventListener("fluid-primitives:field:registered", handler);
|
|
1842
|
+
}
|
|
1843
|
+
}
|
|
1844
|
+
/**
|
|
1845
|
+
* The field measured its starting value from the server HTML; a primitive may hydrate to a
|
|
1846
|
+
* different state (a defaultChecked checkbox, a Select's selected option).
|
|
1847
|
+
*/
|
|
1848
|
+
notifyFieldHydrated() {
|
|
1849
|
+
this.fieldMachine?.send({ type: "BASELINE" });
|
|
1850
|
+
}
|
|
1851
|
+
};
|
|
1852
|
+
|
|
1853
|
+
//#endregion
|
|
1854
|
+
//#region Resources/Private/Client/src/lib/spread-props.ts
|
|
1855
|
+
const lastValues = /* @__PURE__ */ new WeakMap();
|
|
1856
|
+
/**
|
|
1857
|
+
* Like `spreadProps` for a text control, but it does not assign a `value` the control already
|
|
1858
|
+
* shows. Any assignment, even of the same text, makes the browser treat the value as set by a
|
|
1859
|
+
* script, and it stops reporting `tooShort` (`minlength`) for such a value. These controls mirror
|
|
1860
|
+
* what the user typed into their machine, so that assignment would happen on every keystroke.
|
|
1861
|
+
*
|
|
1862
|
+
* As in `spreadProps`, a value is only considered when it changed since the last call: the control
|
|
1863
|
+
* can be one event ahead of the machine, and must not be reset to the machine's older value.
|
|
1864
|
+
*/
|
|
1865
|
+
function spreadTextControlProps(node, attrs, scopeKey) {
|
|
1866
|
+
const { value, ...rest } = attrs;
|
|
1867
|
+
const cleanup = spreadProps(node, rest, scopeKey);
|
|
1868
|
+
if (!lastValues.has(node) || lastValues.get(node) !== value) {
|
|
1869
|
+
const next = value == null ? "" : String(value);
|
|
1870
|
+
if (node.value !== next) node.value = next;
|
|
1871
|
+
}
|
|
1872
|
+
lastValues.set(node, value);
|
|
1873
|
+
return cleanup;
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
//#endregion
|
|
1877
|
+
//#region Resources/Private/Client/src/lib/template.ts
|
|
1878
|
+
/**
|
|
1879
|
+
* Clones the content of a `<template>` part, which must have exactly one root element, and throws
|
|
1880
|
+
* otherwise. It is a `DocumentFragment`, so you can append it directly: `root` stays a valid
|
|
1881
|
+
* reference to the element after the fragment has been emptied by the insertion.
|
|
1882
|
+
*
|
|
1883
|
+
* It works for any "clone a `<template>` and fill it with data that doesn't exist at server render
|
|
1884
|
+
* time" case, such as async search results, file upload previews or the rows of a FieldArray.
|
|
1885
|
+
*/
|
|
1886
|
+
var Template = class extends DocumentFragment {
|
|
1887
|
+
/** The one root element of the clone. */
|
|
1888
|
+
root;
|
|
1889
|
+
/**
|
|
1890
|
+
* The client names of the independent root components found inside the clone, empty without
|
|
1891
|
+
* `options.value` or when there are none. Call their `mountAll()` after you insert the clone into
|
|
1892
|
+
* the document.
|
|
1893
|
+
*/
|
|
1894
|
+
componentNames = [];
|
|
1895
|
+
/**
|
|
1896
|
+
* @param hydrator - The hydrator of the component that owns the `<template>`.
|
|
1897
|
+
* @param part - The name of the `<template>` part.
|
|
1898
|
+
* @param options - See `TemplateOptions`.
|
|
1899
|
+
*/
|
|
1900
|
+
constructor(hydrator, part, options = {}) {
|
|
1901
|
+
super();
|
|
1902
|
+
this.hydrator = hydrator;
|
|
1903
|
+
const templateEl = hydrator.query(part);
|
|
1904
|
+
if (!templateEl) throw new Error(`Template: no <template> found for part "${part}".`);
|
|
1905
|
+
const content = templateEl.content.cloneNode(true);
|
|
1906
|
+
if (content.children.length !== 1) throw new Error(`Template: "${part}" must have exactly one root element, found ${content.children.length}.`);
|
|
1907
|
+
this.append(content);
|
|
1908
|
+
this.root = this.firstElementChild;
|
|
1909
|
+
if (options.value !== void 0) this.componentNames = hydrator.restampValue(this.root, options.value, hydrator.scopeKey(part), options.attributes);
|
|
1910
|
+
movePortals(this);
|
|
1911
|
+
}
|
|
1912
|
+
/** Finds the first element of `part` inside the clone. */
|
|
1913
|
+
query(part) {
|
|
1914
|
+
return this.querySelector(this.hydrator.selector(part));
|
|
1915
|
+
}
|
|
1916
|
+
/** Finds every element of `part` inside the clone. */
|
|
1917
|
+
queryAll(part) {
|
|
1918
|
+
return Array.from(this.querySelectorAll(this.hydrator.selector(part)));
|
|
1919
|
+
}
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
//#endregion
|
|
1923
|
+
//#region Resources/Private/Client/src/lib/uid.ts
|
|
1924
|
+
/**
|
|
1925
|
+
* Creates an id that is unique within the page, e.g. for a part that needs an `id` to reference.
|
|
1926
|
+
*
|
|
1927
|
+
* @param prefix - Starts the id.
|
|
1928
|
+
*/
|
|
1929
|
+
function uid(prefix = "f") {
|
|
1930
|
+
return "«" + prefix + uuid() + "»";
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
//#endregion
|
|
1934
|
+
export { getListCollectionFromHydrationData as $, queryRootEl as A, stringifyFieldPathAsBrackets as B, toErrorArray as C, getVisibleErrorTextIds as D, getControlEl as E, renameFieldMachineForForm as F, applyClientPropConverters as G, trimArraySuffix as H, unregisterFieldMachineForForm as I, destroyComponentsWithin as J, registerClientPropConverters as K, appendFieldPathSegment as L, getFormMachineFor as M, registerFieldMachineForForm as N, hasHelperText as O, registerFormMachine as P, getHydrationData as Q, parseFieldPath as R, suppressValueMissing as S, FIELD_VALUE_CHANGE_EVENT as T, extbase as U, toRegisteredFieldName as V, DelayedIndicator as W, getGlobal as X, getComponentInstance as Y, getGlobals as Z, serializeFieldValue as _, normalizeProps$1 as a, Machine as at, resolveValidation as b, registerFieldMachine as c, connectField as d, mount as et, createFieldHandle as f, getFieldValueFromContainer as g, getFieldElement as h, spreadTextControlProps as i, AsyncList as it, getFieldMachinesFor as j, isInsideField as k, unregisterFieldMachine as l, getCurrentFieldValue as m, Template as n, toKebabCase as nt, mergeProps as o, getComparableFieldValue as p, ComponentHydrator as q, spreadProps as r, Component as rt, FieldAwareComponent as s, uid as t, mountAll as tt, FIELD_DEPENDENCY_CHANGE_EVENT as u, VALID_SNAPSHOT as v, withValueMissing as w, shouldCommit as x, getValiditySnapshot as y, prefixFieldName as z };
|