html-form-field 0.1.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.
@@ -0,0 +1,351 @@
1
+ const triggerOnWrite$1 = (field) => {
2
+ const onWrite = field.options.onWrite;
3
+ if (onWrite) onWrite(field);
4
+ };
5
+
6
+ const checkableMap = {
7
+ option: true,
8
+ radio: true,
9
+ checkbox: true,
10
+ };
11
+
12
+
13
+
14
+ const isElementOf = (v, tagName) => ((v ).tagName === tagName);
15
+
16
+ const formItemList = (field, nodeList) => {
17
+ const list = [];
18
+
19
+ for (const node of nodeList) {
20
+ if (isElementOf(node, "SELECT")) {
21
+ for (const option of node.options) {
22
+ list.push(new OptionItem(field, option));
23
+ }
24
+ } else {
25
+ list.push(new InputItem(field, node));
26
+ }
27
+ }
28
+
29
+ return list
30
+ };
31
+
32
+ class BaseFormItem {
33
+
34
+
35
+
36
+
37
+ constructor(field, node) {
38
+ this.field = field;
39
+ this.node = node;
40
+ }
41
+
42
+ get value() {
43
+ return this.node.value
44
+ }
45
+
46
+ set value(value) {
47
+ this.setValue(value);
48
+ triggerOnWrite$1(this.field);
49
+ }
50
+
51
+ setValue(value) {
52
+ this.node.value = value;
53
+ }
54
+
55
+ get checked() {
56
+ return this.getChecked()
57
+ }
58
+
59
+
60
+
61
+ set checked(checked) {
62
+ const prev = this.checked;
63
+ if (prev !== checked) {
64
+ this.setChecked(checked);
65
+ triggerOnWrite$1(this.field);
66
+ }
67
+ }
68
+
69
+
70
+
71
+ get disabled() {
72
+ return this.node.disabled
73
+ }
74
+
75
+ set disabled(disabled) {
76
+ this.node.disabled = disabled;
77
+ }
78
+
79
+
80
+ }
81
+
82
+ class InputItem extends BaseFormItem {
83
+
84
+
85
+ constructor(entry, node) {
86
+ super(entry, node);
87
+ this.checkable = checkableMap[node.type];
88
+ }
89
+
90
+ getChecked() {
91
+ return (this.node ).checked
92
+ }
93
+
94
+ setChecked(checked) {
95
+ (this.node ).checked = checked;
96
+ }
97
+
98
+ get label() {
99
+ const node = this.node;
100
+ const label = node.closest("label");
101
+ const siblings = label && label.querySelectorAll(node.tagName);
102
+
103
+ if (siblings && siblings.length === 1) {
104
+ const text = pickText(label);
105
+ if (text) return text
106
+ }
107
+
108
+ const labels = node.labels;
109
+ for (const label of labels) {
110
+ const text = pickText(label);
111
+ if (text) return text
112
+ }
113
+
114
+ const text = pickText(node);
115
+ if (text) return text
116
+
117
+ function pickText(node) {
118
+ const text = node.innerText || node.textContent;
119
+ if (text) return text.trim()
120
+ }
121
+ }
122
+ }
123
+
124
+ class OptionItem extends BaseFormItem {
125
+
126
+
127
+ constructor(entry, node) {
128
+ super(entry, node);
129
+ this.checkable = true;
130
+ }
131
+
132
+ getChecked() {
133
+ return this.node.selected
134
+ }
135
+
136
+ setChecked(checked) {
137
+ // Note: Setting false may trigger auto-selection of the first option on select-one
138
+ this.node.selected = checked;
139
+ }
140
+
141
+ get label() {
142
+ const node = this.node;
143
+ const text = node.label || node.textContent;
144
+ if (text) return text.trim()
145
+ }
146
+ }
147
+
148
+ const DELIM = ","; // or use Unit Separator `\x1F` instead
149
+
150
+ const isString = (v) => ("string" === typeof v);
151
+
152
+ const noNullString = (v) => (v == null) ? "" : isString(v) ? v : String(v);
153
+
154
+ const splitString = (v, delim) => (v == null) ? [] : Array.isArray(v) ? v.map(noNullString) : noNullString(v).split(delim);
155
+
156
+ const isHTMLElement = (v) => ("function" === typeof (v ).matches);
157
+
158
+ const formField = (options) => {
159
+ return new FormBridgeImpl(options)
160
+ };
161
+
162
+ const getNodeList = ({form, name}) => {
163
+ const safeName = JSON.stringify(name);
164
+ if (!name) {
165
+ throw new Error(`Invalid name=${safeName}`)
166
+ }
167
+
168
+ const selector = `input[name=${safeName}], textarea[name=${safeName}], button[name=${safeName}], select[name=${safeName}]`;
169
+
170
+ const nodeList = form.querySelectorAll(selector);
171
+ if (!nodeList.length) {
172
+ if (isHTMLElement(form) && form.matches(selector)) {
173
+ return [form]
174
+ }
175
+
176
+ throw new Error(`Not found: name=${safeName}`)
177
+ }
178
+
179
+ return nodeList
180
+ };
181
+
182
+ const updateEventListener = (nodeList, handler) => {
183
+ for (const node of nodeList) {
184
+ node.removeEventListener("change", handler);
185
+ node.addEventListener("change", handler);
186
+ }
187
+ };
188
+
189
+ const triggerOnWrite = (field) => {
190
+ const onWrite = field.options.onWrite;
191
+ if (onWrite) onWrite(field);
192
+ };
193
+
194
+ const triggerOnChange = (field) => {
195
+ const onChange = field.options.onChange;
196
+ if (onChange) onChange(field);
197
+ };
198
+
199
+ const applyBindTo = (bindTo, name, field) => {
200
+ if (!bindTo) return // nothing to be bound
201
+
202
+ delete bindTo[name ];
203
+
204
+ const getValue = () => field.value;
205
+
206
+ const setValue = (value) => {
207
+ field.value = value;
208
+ };
209
+
210
+ Object.defineProperty(bindTo, name, {
211
+ get: getValue,
212
+ set: setValue,
213
+ enumerable: true,
214
+ configurable: true,
215
+ });
216
+ };
217
+
218
+ class FormBridgeImpl {
219
+
220
+
221
+
222
+
223
+
224
+
225
+ constructor(options = {} ) {
226
+ this.options = options;
227
+ const name = this.name = options.name;
228
+ applyBindTo(options.bindTo, name, this);
229
+
230
+ const _onChange = this._onChange = () => {
231
+ triggerOnWrite(this);
232
+ triggerOnChange(this);
233
+ };
234
+
235
+ const nodeList = getNodeList(options);
236
+ updateEventListener(nodeList, _onChange);
237
+ const items = this._items = formItemList(this, nodeList);
238
+
239
+ const defaults = options.defaults;
240
+ if (defaults) {
241
+ if (items[0].checkable) {
242
+ for (const v of defaults) {
243
+ if (this.setValue(v)) break
244
+ }
245
+ } else {
246
+ this.setValue(defaults);
247
+ }
248
+ }
249
+ }
250
+
251
+ get value() {
252
+ const values = this.current().map(v => v.value);
253
+ if (values.length > 1) {
254
+ const delim = this.options.delim || DELIM;
255
+ return values.join(delim)
256
+ } else {
257
+ return values[0]
258
+ }
259
+ }
260
+
261
+ set value(value) {
262
+ this.setValue(value);
263
+ triggerOnWrite(this);
264
+ }
265
+
266
+ setValue(value) {
267
+ const items = this.items();
268
+
269
+ if (items.length === 1 && !items[0].checkable && isString(value)) {
270
+ value = [value];
271
+ }
272
+
273
+ const delim = this.options.delim || DELIM;
274
+ const values = splitString(value, delim);
275
+ let i = 0;
276
+ let assigned = 0;
277
+
278
+ for (const item of items) {
279
+ if (item.checkable) {
280
+ const checked = values.includes(item.value);
281
+ if (checked) assigned++;
282
+ if (item.checked !== checked) {
283
+ item.setChecked(checked);
284
+ }
285
+ } else {
286
+ const value = values[i++];
287
+ const isNull = (value == null);
288
+ item.setValue(isNull ? "" : value);
289
+ if (!isNull) assigned++;
290
+ }
291
+ }
292
+
293
+ return !!assigned
294
+ }
295
+
296
+ current() {
297
+ return this.items().filter(v => (!v.disabled && (!v.checkable || v.checked)))
298
+ }
299
+
300
+ reload() {
301
+ const nodeList = getNodeList(this.options);
302
+ const _onChange = this._onChange;
303
+ updateEventListener(nodeList, _onChange);
304
+ this._items = formItemList(this, nodeList);
305
+ }
306
+
307
+ items() {
308
+ return this._items
309
+ }
310
+
311
+ toggle(value, checked) {
312
+ let result;
313
+ const delim = this.options.delim || DELIM;
314
+ const values = splitString(value, delim);
315
+
316
+ for (const item of this.items()) {
317
+ if (values.includes(item.value)) {
318
+ if (checked != null) {
319
+ result = checked;
320
+ } else {
321
+ result = !item.checked;
322
+ }
323
+ item.checked = result;
324
+ }
325
+ }
326
+
327
+ return result
328
+ }
329
+
330
+ has(value) {
331
+ return !!this.current().find(v => v.value === value)
332
+ }
333
+
334
+ /**
335
+ * Shortcut to access an item by index (operates on items()).
336
+ * Equivalent to items().at(index). Returns undefined if out of range.
337
+ */
338
+ itemAt(index) {
339
+ return this.items().at(index)
340
+ }
341
+
342
+ /**
343
+ * Shortcut to access an item by value (operates on items()).
344
+ * Returns the first FormItem whose value equals the given value, or undefined if not found.
345
+ */
346
+ itemOf(value) {
347
+ return this.items().find(v => v.value === value)
348
+ }
349
+ }
350
+
351
+ export { formField };
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
package/package.json ADDED
@@ -0,0 +1,64 @@
1
+ {
2
+ "name": "html-form-field",
3
+ "description": "Unified interface for HTML form fields with synchronized binding to object properties",
4
+ "version": "0.1.0",
5
+ "author": "Kawanet",
6
+ "c8": {
7
+ "reporter": [
8
+ "text",
9
+ "lcov"
10
+ ],
11
+ "include": [
12
+ "src/**/*.ts"
13
+ ],
14
+ "reportsDir": "coverage"
15
+ },
16
+ "devDependencies": {
17
+ "@rollup/plugin-alias": "^5.1.1",
18
+ "@rollup/plugin-multi-entry": "^7.0.0",
19
+ "@rollup/plugin-node-resolve": "^16.0.3",
20
+ "@rollup/plugin-sucrase": "^5.0.2",
21
+ "@rollup/plugin-terser": "^0.4.4",
22
+ "@types/jsdom": "^27.0.0",
23
+ "@types/node": "^24.7.2",
24
+ "c8": "^10.1.3",
25
+ "chai": "^6.2.0",
26
+ "html-ele": "^0.0.1",
27
+ "jsdom": "^27.0.0",
28
+ "mocha": "^11.7.4",
29
+ "rollup": "^4.52.4",
30
+ "terser": "^5.44.0",
31
+ "typescript": "^5.9.3"
32
+ },
33
+ "exports": {
34
+ ".": {
35
+ "require": "./dist/html-form-field.cjs",
36
+ "import": {
37
+ "types": "./types/html-form-field.d.ts",
38
+ "default": "./dist/html-form-field.mjs"
39
+ }
40
+ }
41
+ },
42
+ "files": [
43
+ "LICENSE",
44
+ "README.md",
45
+ "dist/html-form-field.cjs",
46
+ "dist/html-form-field.min.js",
47
+ "dist/html-form-field.mjs",
48
+ "dist/package.json",
49
+ "src/*.ts",
50
+ "types/*.d.ts"
51
+ ],
52
+ "license": "MIT",
53
+ "main": "./src/index.ts",
54
+ "scripts": {
55
+ "build": "make -C builder",
56
+ "fixpack": "fixpack",
57
+ "prepare": "make -C builder clean all test",
58
+ "test": "node --test",
59
+ "test:coverage": "c8 node --test"
60
+ },
61
+ "sideEffects": false,
62
+ "type": "module",
63
+ "types": "types/html-form-field.d.ts"
64
+ }
@@ -0,0 +1,207 @@
1
+ import type {formField as NS, FormField, FormFieldOptions} from "../types/html-form-field.d.ts"
2
+ import {formItemList} from "./form-item.ts"
3
+
4
+ type FieldEventHandler = (this: NS.FieldElement, ev: Event) => void
5
+
6
+ const DELIM = "," // or use Unit Separator `\x1F` instead
7
+
8
+ const isString = (v: any): v is string => ("string" === typeof v)
9
+
10
+ const noNullString = (v: any): string => (v == null) ? "" : isString(v) ? v : String(v)
11
+
12
+ const splitString = (v: string | string[], delim: string) => (v == null) ? [] : Array.isArray(v) ? v.map(noNullString) : noNullString(v).split(delim)
13
+
14
+ const isHTMLElement = (v: ParentNode): v is HTMLElement => ("function" === typeof (v as HTMLElement).matches)
15
+
16
+ export const formField: typeof NS = (options) => {
17
+ return new FormBridgeImpl(options)
18
+ }
19
+
20
+ const getNodeList = <T>({form, name}: {form: ParentNode, name: NS.StringKeys<T>}): Iterable<NS.FieldElement> => {
21
+ const safeName = JSON.stringify(name)
22
+ if (!name) {
23
+ throw new Error(`Invalid name=${safeName}`)
24
+ }
25
+
26
+ const selector = `input[name=${safeName}], textarea[name=${safeName}], button[name=${safeName}], select[name=${safeName}]`
27
+
28
+ const nodeList = form.querySelectorAll<NS.FieldElement>(selector)
29
+ if (!nodeList.length) {
30
+ if (isHTMLElement(form) && form.matches(selector)) {
31
+ return [form] as NS.FieldElement[]
32
+ }
33
+
34
+ throw new Error(`Not found: name=${safeName}`)
35
+ }
36
+
37
+ return nodeList
38
+ }
39
+
40
+ const updateEventListener = (nodeList: Iterable<NS.FieldElement>, handler: FieldEventHandler) => {
41
+ for (const node of nodeList) {
42
+ node.removeEventListener("change", handler)
43
+ node.addEventListener("change", handler)
44
+ }
45
+ }
46
+
47
+ const triggerOnWrite = <T>(field: FormField<T>) => {
48
+ const onWrite = field.options.onWrite
49
+ if (onWrite) onWrite(field)
50
+ }
51
+
52
+ const triggerOnChange = <T>(field: FormField<T>) => {
53
+ const onChange = field.options.onChange
54
+ if (onChange) onChange(field)
55
+ }
56
+
57
+ const applyBindTo = <T>(bindTo: T, name: NS.StringKeys<T>, field: FormField<T>) => {
58
+ if (!bindTo) return // nothing to be bound
59
+
60
+ delete bindTo[name as keyof T]
61
+
62
+ const getValue = () => field.value
63
+
64
+ const setValue = (value: string) => {
65
+ field.value = value
66
+ }
67
+
68
+ Object.defineProperty(bindTo, name, {
69
+ get: getValue,
70
+ set: setValue,
71
+ enumerable: true,
72
+ configurable: true,
73
+ })
74
+ }
75
+
76
+ class FormBridgeImpl<T = any> implements FormField<T> {
77
+ readonly options: FormFieldOptions<T>
78
+ readonly name: FormField<T>["name"]
79
+
80
+ private _items: NS.FormItem[]
81
+ private readonly _onChange: FieldEventHandler
82
+
83
+ constructor(options: FormFieldOptions<T> = {} as FormFieldOptions<T>) {
84
+ this.options = options
85
+ const name = this.name = options.name
86
+ applyBindTo(options.bindTo, name, this)
87
+
88
+ const _onChange = this._onChange = () => {
89
+ triggerOnWrite(this)
90
+ triggerOnChange(this)
91
+ }
92
+
93
+ const nodeList = getNodeList(options)
94
+ updateEventListener(nodeList, _onChange)
95
+ const items = this._items = formItemList(this, nodeList)
96
+
97
+ const defaults = options.defaults
98
+ if (defaults) {
99
+ if (items[0].checkable) {
100
+ for (const v of defaults) {
101
+ if (this.setValue(v)) break
102
+ }
103
+ } else {
104
+ this.setValue(defaults)
105
+ }
106
+ }
107
+ }
108
+
109
+ get value() {
110
+ const values = this.current().map(v => v.value)
111
+ if (values.length > 1) {
112
+ const delim = this.options.delim || DELIM
113
+ return values.join(delim)
114
+ } else {
115
+ return values[0]
116
+ }
117
+ }
118
+
119
+ set value(value: string) {
120
+ this.setValue(value)
121
+ triggerOnWrite(this)
122
+ }
123
+
124
+ protected setValue(value: string | string[]): boolean {
125
+ const items = this.items()
126
+
127
+ if (items.length === 1 && !items[0].checkable && isString(value)) {
128
+ value = [value]
129
+ }
130
+
131
+ const delim = this.options.delim || DELIM
132
+ const values = splitString(value, delim)
133
+ let i = 0
134
+ let assigned = 0
135
+
136
+ for (const item of items) {
137
+ if (item.checkable) {
138
+ const checked = values.includes(item.value)
139
+ if (checked) assigned++
140
+ if (item.checked !== checked) {
141
+ item.setChecked(checked)
142
+ }
143
+ } else {
144
+ const value = values[i++]
145
+ const isNull = (value == null)
146
+ item.setValue(isNull ? "" : value)
147
+ if (!isNull) assigned++
148
+ }
149
+ }
150
+
151
+ return !!assigned
152
+ }
153
+
154
+ current() {
155
+ return this.items().filter(v => (!v.disabled && (!v.checkable || v.checked)))
156
+ }
157
+
158
+ reload(): void {
159
+ const nodeList = getNodeList(this.options)
160
+ const _onChange = this._onChange
161
+ updateEventListener(nodeList, _onChange)
162
+ this._items = formItemList(this, nodeList)
163
+ }
164
+
165
+ items(): NS.FormItem[] {
166
+ return this._items
167
+ }
168
+
169
+ toggle(value: string, checked?: boolean): boolean {
170
+ let result: boolean
171
+ const delim = this.options.delim || DELIM
172
+ const values = splitString(value, delim)
173
+
174
+ for (const item of this.items()) {
175
+ if (values.includes(item.value)) {
176
+ if (checked != null) {
177
+ result = checked
178
+ } else {
179
+ result = !item.checked
180
+ }
181
+ item.checked = result
182
+ }
183
+ }
184
+
185
+ return result
186
+ }
187
+
188
+ has(value: string): boolean {
189
+ return !!this.current().find(v => v.value === value)
190
+ }
191
+
192
+ /**
193
+ * Shortcut to access an item by index (operates on items()).
194
+ * Equivalent to items().at(index). Returns undefined if out of range.
195
+ */
196
+ itemAt(index: number): NS.FormItem | undefined {
197
+ return this.items().at(index)
198
+ }
199
+
200
+ /**
201
+ * Shortcut to access an item by value (operates on items()).
202
+ * Returns the first FormItem whose value equals the given value, or undefined if not found.
203
+ */
204
+ itemOf(value: string): NS.FormItem | undefined {
205
+ return this.items().find(v => v.value === value)
206
+ }
207
+ }