elements-kit 0.18.2 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ui/overlay/index.css +70 -0
- package/dist/ui/overlay/index.d.mts +209 -0
- package/dist/ui/overlay/index.mjs +1001 -0
- package/dist/ui/overlay/overlay.css +332 -0
- package/dist/utilities/form-object.d.mts +96 -0
- package/dist/utilities/form-object.mjs +285 -0
- package/dist/utilities/form-object.test.d.mts +1 -0
- package/dist/utilities/form-object.test.mjs +408 -0
- package/package.json +5 -5
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
//#region src/utilities/form-object.ts
|
|
2
|
+
/** True when the control is a button / submit / reset / image. */
|
|
3
|
+
function isButton(control) {
|
|
4
|
+
if (control instanceof HTMLButtonElement) return true;
|
|
5
|
+
if (control instanceof HTMLInputElement) return control.type === "submit" || control.type === "reset" || control.type === "button" || control.type === "image";
|
|
6
|
+
return false;
|
|
7
|
+
}
|
|
8
|
+
/** Drop disabled controls (mirrors native `FormData`). */
|
|
9
|
+
const skipDisabled = (field) => field.control.disabled ? null : field;
|
|
10
|
+
/** Drop submit / reset / button / image controls and `<button>` elements. */
|
|
11
|
+
const skipButtons = (field) => isButton(field.control) ? null : field;
|
|
12
|
+
/** Drop fields whose control is an unchecked checkbox / radio. */
|
|
13
|
+
const dropUnchecked = (field) => field.checked === false ? null : field;
|
|
14
|
+
/**
|
|
15
|
+
* Include unchecked **checkboxes** with the given `value` instead of dropping
|
|
16
|
+
* them. Unchecked **radios** are still dropped (only the selected radio is
|
|
17
|
+
* meaningful). Use this **in place of** {@link dropUnchecked}.
|
|
18
|
+
*
|
|
19
|
+
* @example transforms: [skipDisabled, skipButtons, uncheckedAs(false)]
|
|
20
|
+
*/
|
|
21
|
+
function uncheckedAs(value) {
|
|
22
|
+
return (field) => {
|
|
23
|
+
if (field.checked !== false) return field;
|
|
24
|
+
if (field.control instanceof HTMLInputElement && field.control.type === "radio") return null;
|
|
25
|
+
return {
|
|
26
|
+
...field,
|
|
27
|
+
value,
|
|
28
|
+
checked: void 0
|
|
29
|
+
};
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Drop fields whose value is empty: `""`, `null`, or an empty array. */
|
|
33
|
+
const skipEmpty = (field) => {
|
|
34
|
+
const v = field.value;
|
|
35
|
+
if (v === "" || v === null) return null;
|
|
36
|
+
if (Array.isArray(v) && v.length === 0) return null;
|
|
37
|
+
return field;
|
|
38
|
+
};
|
|
39
|
+
/** Default pipeline: skip disabled controls, buttons, and unchecked checkboxes/radios. */
|
|
40
|
+
const defaultTransforms = [
|
|
41
|
+
skipDisabled,
|
|
42
|
+
skipButtons,
|
|
43
|
+
dropUnchecked
|
|
44
|
+
];
|
|
45
|
+
/** A non-negative integer string (e.g. "0", "12") — denotes an array index. */
|
|
46
|
+
function isIndex(segment) {
|
|
47
|
+
return /^(0|[1-9]\d*)$/.test(segment);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* A trailing `[]` marks an auto-append array field (PHP / form-data-json
|
|
51
|
+
* convention) — `colors[]` appends to the `colors` array, `a.b[]` to `a.b`.
|
|
52
|
+
* Returns the dot-path without the suffix, or `null` when absent.
|
|
53
|
+
*/
|
|
54
|
+
function arrayFieldPath(name) {
|
|
55
|
+
return name.endsWith("[]") ? name.slice(0, -2) : null;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Segments that could walk into an object's prototype. Writing through them
|
|
59
|
+
* enables prototype-pollution; reading them leaks internal objects. Paths
|
|
60
|
+
* containing any of these are ignored entirely.
|
|
61
|
+
*/
|
|
62
|
+
function hasUnsafeKey(keys) {
|
|
63
|
+
return keys.some((k) => k === "__proto__" || k === "prototype" || k === "constructor");
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Write `value` into `target` at the dot-notation `path`. Object segments
|
|
67
|
+
* create plain objects; integer segments create/extend arrays. Paths
|
|
68
|
+
* containing prototype-polluting segments are ignored.
|
|
69
|
+
*
|
|
70
|
+
* @example setPath(o, "user.tags.0", "a") // { user: { tags: ["a"] } }
|
|
71
|
+
*/
|
|
72
|
+
function setPath(target, path, value) {
|
|
73
|
+
const keys = path.split(".");
|
|
74
|
+
if (hasUnsafeKey(keys)) return;
|
|
75
|
+
let node = target;
|
|
76
|
+
for (let i = 0; i < keys.length; i++) {
|
|
77
|
+
const key = keys[i];
|
|
78
|
+
if (i === keys.length - 1) {
|
|
79
|
+
node[key] = value;
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
const nextKey = keys[i + 1];
|
|
83
|
+
const childShouldBeArray = isIndex(nextKey);
|
|
84
|
+
const existing = node[key];
|
|
85
|
+
if (existing === void 0 || typeof existing !== "object" || existing === null) node[key] = childShouldBeArray ? [] : {};
|
|
86
|
+
node = node[key];
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
/** Read the value at the dot-notation `path` from `source`, or `undefined`. */
|
|
90
|
+
function getPath(source, path) {
|
|
91
|
+
const keys = path.split(".");
|
|
92
|
+
if (hasUnsafeKey(keys)) return void 0;
|
|
93
|
+
let node = source;
|
|
94
|
+
for (const key of keys) {
|
|
95
|
+
if (node === null || typeof node !== "object") return void 0;
|
|
96
|
+
node = node[key];
|
|
97
|
+
}
|
|
98
|
+
return node;
|
|
99
|
+
}
|
|
100
|
+
/** Extract one raw {@link Field} from a named control, pre-transform. */
|
|
101
|
+
function extractField(control) {
|
|
102
|
+
if (control instanceof HTMLInputElement) {
|
|
103
|
+
const type = control.type;
|
|
104
|
+
if (type === "checkbox") return {
|
|
105
|
+
control,
|
|
106
|
+
name: control.name,
|
|
107
|
+
value: control.value || "on",
|
|
108
|
+
checked: control.checked
|
|
109
|
+
};
|
|
110
|
+
if (type === "radio") return {
|
|
111
|
+
control,
|
|
112
|
+
name: control.name,
|
|
113
|
+
value: control.value,
|
|
114
|
+
checked: control.checked
|
|
115
|
+
};
|
|
116
|
+
if (type === "file") {
|
|
117
|
+
const value = control.multiple ? Array.from(control.files ?? []) : control.files?.[0] ?? null;
|
|
118
|
+
return {
|
|
119
|
+
control,
|
|
120
|
+
name: control.name,
|
|
121
|
+
value
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
control,
|
|
126
|
+
name: control.name,
|
|
127
|
+
value: control.value
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
if (control instanceof HTMLSelectElement && control.multiple) return {
|
|
131
|
+
control,
|
|
132
|
+
name: control.name,
|
|
133
|
+
value: Array.from(control.selectedOptions, (o) => o.value)
|
|
134
|
+
};
|
|
135
|
+
return {
|
|
136
|
+
control,
|
|
137
|
+
name: control.name,
|
|
138
|
+
value: control.value
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Extracts an {@link HTMLFormElement} into a nested plain object (and writes one
|
|
143
|
+
* back) using dot-notation field names — `name="user.address.city"` nests into
|
|
144
|
+
* objects, numeric segments like `name="tags.0"` build arrays. A static snapshot:
|
|
145
|
+
* values are read/written at call time, no reactivity.
|
|
146
|
+
*
|
|
147
|
+
* Extraction runs each field through a composable {@link FormFieldTransform}
|
|
148
|
+
* pipeline ({@link defaultTransforms} by default), which decides inclusion and
|
|
149
|
+
* may rewrite names/values. Mirrors the ergonomics of the native `FormData`
|
|
150
|
+
* constructor.
|
|
151
|
+
*
|
|
152
|
+
* @example
|
|
153
|
+
* ```ts
|
|
154
|
+
* import { FormObject, defaultTransforms, uncheckedAs }
|
|
155
|
+
* from "elements-kit/utilities/form-object";
|
|
156
|
+
*
|
|
157
|
+
* // <input name="user.name"> <input name="tags.0"> <input name="tags.1">
|
|
158
|
+
* const data = new FormObject(form).toObject();
|
|
159
|
+
* // => { user: { name: "..." }, tags: ["...", "..."] }
|
|
160
|
+
*
|
|
161
|
+
* // Replace the pipeline — include unchecked checkboxes as false
|
|
162
|
+
* new FormObject(form, {
|
|
163
|
+
* transforms: [...defaultTransforms.slice(0, 2), uncheckedAs(false)],
|
|
164
|
+
* }).toObject();
|
|
165
|
+
*
|
|
166
|
+
* new FormObject(form).fromObject({ user: { name: "Wael" }, tags: ["a", "b"] });
|
|
167
|
+
* ```
|
|
168
|
+
*/
|
|
169
|
+
var FormObject = class {
|
|
170
|
+
#form;
|
|
171
|
+
#transforms;
|
|
172
|
+
constructor(form, options = {}) {
|
|
173
|
+
this.#form = form;
|
|
174
|
+
this.#transforms = options.transforms ?? defaultTransforms;
|
|
175
|
+
}
|
|
176
|
+
/** Run a field through the transform pipeline; `null` if any transform drops it. */
|
|
177
|
+
#applyTransforms(field) {
|
|
178
|
+
let current = field;
|
|
179
|
+
for (const transform of this.#transforms) {
|
|
180
|
+
if (current === null) return null;
|
|
181
|
+
current = transform(current);
|
|
182
|
+
}
|
|
183
|
+
return current;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Place a field into `result`. Array-ness is explicit, like form-data-json:
|
|
187
|
+
* a name ending in `[]` appends to an array at that path (so a single
|
|
188
|
+
* `colors[]` still yields `["x"]` and an empty one stays `[]`), while a bare
|
|
189
|
+
* name is scalar — if several controls share it, the **last value wins**.
|
|
190
|
+
* Use explicit indices (`tags.0`, `tags.1`) or `[]` to build arrays.
|
|
191
|
+
*/
|
|
192
|
+
#assign(result, field) {
|
|
193
|
+
const { name, value } = field;
|
|
194
|
+
const arrayPath = arrayFieldPath(name);
|
|
195
|
+
if (arrayPath !== null) {
|
|
196
|
+
let arr = getPath(result, arrayPath);
|
|
197
|
+
if (!Array.isArray(arr)) {
|
|
198
|
+
arr = [];
|
|
199
|
+
setPath(result, arrayPath, arr);
|
|
200
|
+
}
|
|
201
|
+
arr.push(value);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
setPath(result, name, value);
|
|
205
|
+
}
|
|
206
|
+
/** Snapshot the form into a nested object built from dot-notation names. */
|
|
207
|
+
toObject() {
|
|
208
|
+
const result = {};
|
|
209
|
+
for (const el of this.#form.elements) {
|
|
210
|
+
const control = el;
|
|
211
|
+
if (!control.name) continue;
|
|
212
|
+
const path = arrayFieldPath(control.name);
|
|
213
|
+
if (path !== null && getPath(result, path) === void 0) setPath(result, path, []);
|
|
214
|
+
}
|
|
215
|
+
for (const el of this.#form.elements) {
|
|
216
|
+
if (!el.name) continue;
|
|
217
|
+
const field = this.#applyTransforms(extractField(el));
|
|
218
|
+
if (field === null) continue;
|
|
219
|
+
this.#assign(result, field);
|
|
220
|
+
}
|
|
221
|
+
return result;
|
|
222
|
+
}
|
|
223
|
+
/** Alias for {@link toObject} — lets `JSON.stringify(instance)` work. */
|
|
224
|
+
toJSON() {
|
|
225
|
+
return this.toObject();
|
|
226
|
+
}
|
|
227
|
+
/** True when the control can be written/cleared (named and not disabled). */
|
|
228
|
+
#isWritable(el) {
|
|
229
|
+
const control = el;
|
|
230
|
+
return !!control.name && !control.disabled;
|
|
231
|
+
}
|
|
232
|
+
/** Write values from a nested object back onto the form's named controls. */
|
|
233
|
+
fromObject(data) {
|
|
234
|
+
for (const el of this.#form.elements) {
|
|
235
|
+
if (!this.#isWritable(el)) continue;
|
|
236
|
+
const control = el;
|
|
237
|
+
const value = getPath(data, arrayFieldPath(control.name) ?? control.name);
|
|
238
|
+
if (value === void 0) continue;
|
|
239
|
+
if (control instanceof HTMLInputElement) {
|
|
240
|
+
const type = control.type;
|
|
241
|
+
if (type === "checkbox") {
|
|
242
|
+
if (Array.isArray(value)) control.checked = value.includes(control.value);
|
|
243
|
+
else if (typeof value === "boolean") control.checked = value;
|
|
244
|
+
else control.checked = value === control.value || value === "on";
|
|
245
|
+
continue;
|
|
246
|
+
}
|
|
247
|
+
if (type === "radio") {
|
|
248
|
+
control.checked = control.value === value;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
if (type === "file") continue;
|
|
252
|
+
control.value = value == null ? "" : String(value);
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
if (control instanceof HTMLSelectElement && control.multiple) {
|
|
256
|
+
const values = (Array.isArray(value) ? value : [value]).map(String);
|
|
257
|
+
for (const option of control.options) option.selected = values.includes(option.value);
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
control.value = value == null ? "" : String(value);
|
|
261
|
+
}
|
|
262
|
+
return this;
|
|
263
|
+
}
|
|
264
|
+
/** Reset every named control to its empty/default state. */
|
|
265
|
+
clear() {
|
|
266
|
+
for (const el of this.#form.elements) {
|
|
267
|
+
if (!this.#isWritable(el)) continue;
|
|
268
|
+
const control = el;
|
|
269
|
+
if (control instanceof HTMLInputElement) {
|
|
270
|
+
const type = control.type;
|
|
271
|
+
if (type === "checkbox" || type === "radio") control.checked = false;
|
|
272
|
+
else control.value = "";
|
|
273
|
+
continue;
|
|
274
|
+
}
|
|
275
|
+
if (control instanceof HTMLSelectElement) {
|
|
276
|
+
for (const option of control.options) option.selected = false;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
control.value = "";
|
|
280
|
+
}
|
|
281
|
+
return this;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
//#endregion
|
|
285
|
+
export { FormObject, defaultTransforms, dropUnchecked, skipButtons, skipDisabled, skipEmpty, uncheckedAs };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { };
|