html-form-field 0.2.0 → 0.4.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/LICENSE +1 -1
- package/README.md +40 -21
- package/browser/import.js +2 -0
- package/browser/package.json +1 -0
- package/dist/html-form-field.cjs +57 -14
- package/dist/html-form-field.min.js +1 -1
- package/dist/html-form-field.mjs +57 -14
- package/package.json +48 -29
- package/src/form-field.ts +55 -16
- package/src/form-item.ts +1 -1
- package/src/index.ts +8 -4
- package/types/html-form-field.d.ts +100 -77
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
# html-form-field
|
|
2
2
|
|
|
3
|
-
Unified interface for HTML form fields with
|
|
3
|
+
Unified interface for HTML form fields with two-way binding to object properties.
|
|
4
4
|
|
|
5
|
-
[](https://github.com/kawanet/html-form-field/actions/)
|
|
6
6
|
[](https://www.npmjs.com/package/html-form-field)
|
|
7
7
|
[](https://cdn.jsdelivr.net/npm/html-form-field/dist/html-form-field.min.js)
|
|
8
8
|
|
|
9
|
-
-
|
|
10
|
-
- Two-way binding between form
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
9
|
+
- A single getter/setter API for text inputs, checkboxes, radio buttons, and `<select>` elements.
|
|
10
|
+
- Two-way binding between a form field and an object property: assignments flow in either direction.
|
|
11
|
+
- Change-detection hooks via `onWrite` (any value write) and `onChange` (user-driven change events).
|
|
12
|
+
- Tiny browser build — [html-form-field.min.js](https://cdn.jsdelivr.net/npm/html-form-field/dist/html-form-field.min.js) is under 4 KB minified and under 2 KB gzipped.
|
|
13
|
+
- First-class TypeScript types — see [html-form-field.d.ts](https://github.com/kawanet/html-form-field/blob/main/types/html-form-field.d.ts) for the full surface.
|
|
14
14
|
|
|
15
15
|
## SYNOPSIS
|
|
16
16
|
|
|
@@ -29,15 +29,14 @@ const ctx = {} as Context
|
|
|
29
29
|
|
|
30
30
|
formField({form, bindTo: ctx, name: "nickname"})
|
|
31
31
|
|
|
32
|
-
console.log(ctx.nickname) // reads from form field
|
|
32
|
+
console.log(ctx.nickname) // reads from the form field
|
|
33
33
|
|
|
34
|
-
ctx.nickname = "John"
|
|
34
|
+
ctx.nickname = "John" // writes back to the form field
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
#### HTML Example
|
|
38
38
|
|
|
39
39
|
```html
|
|
40
|
-
|
|
41
40
|
<form>
|
|
42
41
|
<ul>
|
|
43
42
|
<li>Nickname: <input type="text" name="nickname" value="Alice"></li>
|
|
@@ -56,9 +55,9 @@ ctx.nickname = "John" // updates form field
|
|
|
56
55
|
```js
|
|
57
56
|
const email = formField({form, name: "email"})
|
|
58
57
|
|
|
59
|
-
console.log(email.value)
|
|
58
|
+
console.log(email.value) // read the current value
|
|
60
59
|
|
|
61
|
-
email.value = "john@example.com" //
|
|
60
|
+
email.value = "john@example.com" // assign a new value
|
|
62
61
|
```
|
|
63
62
|
|
|
64
63
|
#### Multiple Selections
|
|
@@ -66,19 +65,17 @@ email.value = "john@example.com" // update value
|
|
|
66
65
|
```js
|
|
67
66
|
const favo = formField({form, name: "favo", delim: ","})
|
|
68
67
|
|
|
69
|
-
favo.toggle("tech")
|
|
70
|
-
|
|
71
|
-
favo.toggle("
|
|
68
|
+
favo.toggle("tech") // toggle one checkbox
|
|
69
|
+
favo.toggle("travel", true) // force-check
|
|
70
|
+
favo.toggle("trading", false) // force-uncheck
|
|
72
71
|
|
|
73
|
-
favo.
|
|
72
|
+
console.log(favo.has("travel")) // is this option currently selected?
|
|
74
73
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
// Shortcut to item by index. Equivalent to items().at(index))
|
|
74
|
+
// Shortcut for items().at(index)
|
|
78
75
|
const firstItem = favo.itemAt(0)
|
|
79
76
|
console.log(firstItem.checked)
|
|
80
77
|
|
|
81
|
-
// Shortcut
|
|
78
|
+
// Shortcut for items().find(v => v.value === value)
|
|
82
79
|
const travelItem = favo.itemOf("travel")
|
|
83
80
|
console.log(travelItem.checked)
|
|
84
81
|
```
|
|
@@ -96,7 +93,29 @@ formField({
|
|
|
96
93
|
})
|
|
97
94
|
```
|
|
98
95
|
|
|
99
|
-
##
|
|
96
|
+
## SEE ALSO
|
|
100
97
|
|
|
101
98
|
- https://www.npmjs.com/package/html-form-field
|
|
102
99
|
- https://github.com/kawanet/html-form-field
|
|
100
|
+
|
|
101
|
+
## MIT LICENSE
|
|
102
|
+
|
|
103
|
+
Copyright (c) 2025-2026 Yusuke Kawasaki
|
|
104
|
+
|
|
105
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
106
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
107
|
+
in the Software without restriction, including without limitation the rights
|
|
108
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
109
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
110
|
+
furnished to do so, subject to the following conditions:
|
|
111
|
+
|
|
112
|
+
The above copyright notice and this permission notice shall be included in all
|
|
113
|
+
copies or substantial portions of the Software.
|
|
114
|
+
|
|
115
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
116
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
117
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
118
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
119
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
120
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
121
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"type":"commonjs"}
|
package/dist/html-form-field.cjs
CHANGED
|
@@ -147,6 +147,10 @@ class OptionItem extends BaseFormItem {
|
|
|
147
147
|
}
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
|
|
150
154
|
const DELIM = ","; // or use Unit Separator `\x1F` instead
|
|
151
155
|
|
|
152
156
|
const isString = (v) => ("string" === typeof v);
|
|
@@ -170,20 +174,29 @@ const getNodeList = ({form, name}) => {
|
|
|
170
174
|
const selector = `input[name=${safeName}], textarea[name=${safeName}], button[name=${safeName}], select[name=${safeName}]`;
|
|
171
175
|
|
|
172
176
|
const nodeList = form.querySelectorAll(selector);
|
|
173
|
-
if (!nodeList.length) {
|
|
174
|
-
|
|
175
|
-
return [form]
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
throw new Error(`Not found: name=${safeName}`)
|
|
177
|
+
if (!nodeList.length && isHTMLElement(form) && form.matches(selector)) {
|
|
178
|
+
return [form]
|
|
179
179
|
}
|
|
180
180
|
|
|
181
|
+
// No throw on a 0-match result here: a freshly rendered form may not yet
|
|
182
|
+
// contain the controls (dynamic radio/checkbox groups, lazy-populated
|
|
183
|
+
// <select>, etc.). The error surfaces lazily from `items()` instead.
|
|
181
184
|
return nodeList
|
|
182
185
|
};
|
|
183
186
|
|
|
184
|
-
|
|
187
|
+
// Detach `change` listeners from elements that were part of the previous
|
|
188
|
+
// `_fields` snapshot. Pair this with `addEventListeners` on the new
|
|
189
|
+
// snapshot inside `reload()` so a control that dropped out of the
|
|
190
|
+
// selector (renamed, removed from the DOM, or moved out of the form)
|
|
191
|
+
// stops firing this field's handlers.
|
|
192
|
+
const removeEventListeners = (nodeList, handler) => {
|
|
185
193
|
for (const node of nodeList) {
|
|
186
194
|
node.removeEventListener("change", handler);
|
|
195
|
+
}
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const addEventListeners = (nodeList, handler) => {
|
|
199
|
+
for (const node of nodeList) {
|
|
187
200
|
node.addEventListener("change", handler);
|
|
188
201
|
}
|
|
189
202
|
};
|
|
@@ -222,6 +235,11 @@ class FormBridgeImpl {
|
|
|
222
235
|
|
|
223
236
|
|
|
224
237
|
|
|
238
|
+
// `null` when the most recent getNodeList() returned zero matches.
|
|
239
|
+
// Methods that need item-level access (items, value, current, etc.)
|
|
240
|
+
// surface the error lazily; closest() still works because it walks
|
|
241
|
+
// from `_fields[0]`, which is simply `undefined` in this state.
|
|
242
|
+
|
|
225
243
|
|
|
226
244
|
|
|
227
245
|
constructor(options = {} ) {
|
|
@@ -234,9 +252,9 @@ class FormBridgeImpl {
|
|
|
234
252
|
triggerOnChange(this);
|
|
235
253
|
};
|
|
236
254
|
|
|
237
|
-
const
|
|
238
|
-
|
|
239
|
-
this._items = formItemList(this,
|
|
255
|
+
const fields = this._fields = Array.from(getNodeList(options));
|
|
256
|
+
addEventListeners(fields, _onChange);
|
|
257
|
+
this._items = fields.length ? formItemList(this, fields) : null;
|
|
240
258
|
|
|
241
259
|
const defaults = options.defaults;
|
|
242
260
|
if (defaults) {
|
|
@@ -296,16 +314,41 @@ class FormBridgeImpl {
|
|
|
296
314
|
}
|
|
297
315
|
|
|
298
316
|
reload() {
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
317
|
+
// Detach handlers from the previous snapshot first — `getNodeList()`
|
|
318
|
+
// can now legitimately return fewer (or zero) controls, and any
|
|
319
|
+
// node that drops out of the selector must stop firing this
|
|
320
|
+
// field's `change` listener.
|
|
321
|
+
removeEventListeners(this._fields, this._onChange);
|
|
322
|
+
const fields = this._fields = Array.from(getNodeList(this.options));
|
|
323
|
+
addEventListeners(fields, this._onChange);
|
|
324
|
+
this._items = fields.length ? formItemList(this, fields) : null;
|
|
303
325
|
}
|
|
304
326
|
|
|
305
327
|
items() {
|
|
328
|
+
if (!this._items) {
|
|
329
|
+
throw new Error(`Not found: name=${JSON.stringify(this.name)}`)
|
|
330
|
+
}
|
|
306
331
|
return this._items
|
|
307
332
|
}
|
|
308
333
|
|
|
334
|
+
/**
|
|
335
|
+
* Walk up from the field's first element (inclusive) and return the
|
|
336
|
+
* closest ancestor matching the selector. Useful for reaching the
|
|
337
|
+
* container element when the field is a `<select>` (so you can
|
|
338
|
+
* append `<option>` elements before calling `reload()`) or when
|
|
339
|
+
* the field is part of a checkbox/radio group inside a wrapper.
|
|
340
|
+
*
|
|
341
|
+
* For multi-element fields (checkbox/radio groups), the walk starts
|
|
342
|
+
* from the first element only — callers that need a different
|
|
343
|
+
* starting point can use `items().at(i)?.node.closest(selector)`.
|
|
344
|
+
*
|
|
345
|
+
* Returns `null` when no ancestor matches, matching the contract
|
|
346
|
+
* of `Element.closest()`.
|
|
347
|
+
*/
|
|
348
|
+
closest(selector) {
|
|
349
|
+
return _nullishCoalesce(_optionalChain([this, 'access', _ => _._fields, 'access', _2 => _2[0], 'optionalAccess', _3 => _3.closest, 'call', _4 => _4(selector)]), () => ( null))
|
|
350
|
+
}
|
|
351
|
+
|
|
309
352
|
toggle(value, checked) {
|
|
310
353
|
let result;
|
|
311
354
|
const delim = this.options.delim || DELIM;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
var formField=function(e){"use strict";const t=e=>{const t=e.options.onWrite;t&&t(e)},s={option:!0,radio:!0,checkbox:!0},n=(e,t)=>e.tagName===t,i=(e,t)=>{const s=[];for(const i of t)if(n(i,"SELECT"))for(const t of i.options)s.push(new
|
|
1
|
+
var formField=function(e){"use strict";const t=e=>{const t=e.options.onWrite;t&&t(e)},s={option:!0,radio:!0,checkbox:!0},n=(e,t)=>e.tagName===t,i=(e,t)=>{const s=[];for(const i of t)if(n(i,"SELECT"))for(const t of i.options)s.push(new l(e,t));else s.push(new r(e,i));return s};class o{constructor(e,t){this.field=e,this.node=t}get value(){return this.node.value}set value(e){this.setValue(e),t(this.field)}setValue(e){this.node.value=e}get checked(){return this.getChecked()}set checked(e){this.checked!==e&&(this.setChecked(e),t(this.field))}get disabled(){return this.node.disabled}set disabled(e){this.node.disabled=e}}class r extends o{constructor(e,t){super(e,t),this.checkable=s[t.type]}getChecked(){return this.node.checked}setChecked(e){this.node.checked=e}get label(){const e=this.node,t=e.closest("label"),s=t&&t.querySelectorAll(e.tagName);if(s&&1===s.length){const e=o(t);if(e)return e}const n=e.labels;for(const e of n){const t=o(e);if(t)return t}const i=o(e);if(i)return i;function o(e){const t=e.innerText||e.textContent;if(t)return t.trim()}}}class l extends o{constructor(e,t){super(e,t),this.checkable=!0}getChecked(){return this.node.selected}setChecked(e){this.node.selected=e}get label(){const e=this.node,t=e.label||e.textContent;if(t)return t.trim()}}const c=e=>"string"==typeof e,h=e=>null==e?"":c(e)?e:String(e),a=(e,t)=>null==e?[]:Array.isArray(e)?e.map(h):h(e).split(t),u=({form:e,name:t})=>{const s=JSON.stringify(t);if(!t)throw new Error(`Invalid name=${s}`);const n=`input[name=${s}], textarea[name=${s}], button[name=${s}], select[name=${s}]`,i=e.querySelectorAll(n);return!i.length&&"function"==typeof e.matches&&e.matches(n)?[e]:i},d=(e,t)=>{for(const s of e)s.addEventListener("change",t)},f=e=>{const t=e.options.onWrite;t&&t(e)};class m{constructor(e={}){this.options=e;const t=this.name=e.name;((e,t,s)=>{if(!e)return;delete e[t],Object.defineProperty(e,t,{get:()=>s.value,set:e=>{s.value=e},enumerable:!0,configurable:!0})})(e.bindTo,t,this);const s=this._onChange=()=>{f(this),(e=>{const t=e.options.onChange;t&&t(e)})(this)},n=this._fields=Array.from(u(e));d(n,s),this._items=n.length?i(this,n):null;const o=e.defaults;if(o)for(const e of o)if(this.setValue(e))break}get value(){const e=this.current().map(e=>e.value);if(e.length>1){const t=this.options.delim||",";return e.join(t)}return e[0]}set value(e){this.setValue(e),f(this)}setValue(e){const t=this.items();1===t.length&&!t[0].checkable&&c(e)&&(e=[e]);const s=this.options.delim||",",n=a(e,s);let i=0,o=0;for(const e of t)if(e.checkable){const t=n.includes(e.value);t&&o++,e.checked!==t&&e.setChecked(t)}else{const t=n[i++],s=null==t;e.setValue(s?"":t),s||o++}return!!o}current(){return this.items().filter(e=>!e.disabled&&(!e.checkable||e.checked))}reload(){((e,t)=>{for(const s of e)s.removeEventListener("change",t)})(this._fields,this._onChange);const e=this._fields=Array.from(u(this.options));d(e,this._onChange),this._items=e.length?i(this,e):null}items(){if(!this._items)throw new Error(`Not found: name=${JSON.stringify(this.name)}`);return this._items}closest(e){return t=function(e){let t,s=e[0],n=1;for(;n<e.length;){const i=e[n],o=e[n+1];if(n+=2,("optionalAccess"===i||"optionalCall"===i)&&null==s)return;"access"===i||"optionalAccess"===i?(t=s,s=o(s)):"call"!==i&&"optionalCall"!==i||(s=o((...e)=>s.call(t,...e)),t=void 0)}return s}([this,"access",e=>e._fields,"access",e=>e[0],"optionalAccess",e=>e.closest,"call",t=>t(e)]),s=()=>null,null!=t?t:s();var t,s}toggle(e,t){let s;const n=this.options.delim||",",i=a(e,n);for(const e of this.items())i.includes(e.value)&&(s=null!=t?t:!e.checked,e.checked=s);return s}has(e){return!!this.current().find(t=>t.value===e)}itemAt(e){return this.items().at(e)}itemOf(e){return this.items().find(t=>t.value===e)}}return e.formField=e=>new m(e),"undefined"!=typeof module&&(module.exports=e),e.formField}({});
|
package/dist/html-form-field.mjs
CHANGED
|
@@ -145,6 +145,10 @@ class OptionItem extends BaseFormItem {
|
|
|
145
145
|
}
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
|
|
148
152
|
const DELIM = ","; // or use Unit Separator `\x1F` instead
|
|
149
153
|
|
|
150
154
|
const isString = (v) => ("string" === typeof v);
|
|
@@ -168,20 +172,29 @@ const getNodeList = ({form, name}) => {
|
|
|
168
172
|
const selector = `input[name=${safeName}], textarea[name=${safeName}], button[name=${safeName}], select[name=${safeName}]`;
|
|
169
173
|
|
|
170
174
|
const nodeList = form.querySelectorAll(selector);
|
|
171
|
-
if (!nodeList.length) {
|
|
172
|
-
|
|
173
|
-
return [form]
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
throw new Error(`Not found: name=${safeName}`)
|
|
175
|
+
if (!nodeList.length && isHTMLElement(form) && form.matches(selector)) {
|
|
176
|
+
return [form]
|
|
177
177
|
}
|
|
178
178
|
|
|
179
|
+
// No throw on a 0-match result here: a freshly rendered form may not yet
|
|
180
|
+
// contain the controls (dynamic radio/checkbox groups, lazy-populated
|
|
181
|
+
// <select>, etc.). The error surfaces lazily from `items()` instead.
|
|
179
182
|
return nodeList
|
|
180
183
|
};
|
|
181
184
|
|
|
182
|
-
|
|
185
|
+
// Detach `change` listeners from elements that were part of the previous
|
|
186
|
+
// `_fields` snapshot. Pair this with `addEventListeners` on the new
|
|
187
|
+
// snapshot inside `reload()` so a control that dropped out of the
|
|
188
|
+
// selector (renamed, removed from the DOM, or moved out of the form)
|
|
189
|
+
// stops firing this field's handlers.
|
|
190
|
+
const removeEventListeners = (nodeList, handler) => {
|
|
183
191
|
for (const node of nodeList) {
|
|
184
192
|
node.removeEventListener("change", handler);
|
|
193
|
+
}
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const addEventListeners = (nodeList, handler) => {
|
|
197
|
+
for (const node of nodeList) {
|
|
185
198
|
node.addEventListener("change", handler);
|
|
186
199
|
}
|
|
187
200
|
};
|
|
@@ -220,6 +233,11 @@ class FormBridgeImpl {
|
|
|
220
233
|
|
|
221
234
|
|
|
222
235
|
|
|
236
|
+
// `null` when the most recent getNodeList() returned zero matches.
|
|
237
|
+
// Methods that need item-level access (items, value, current, etc.)
|
|
238
|
+
// surface the error lazily; closest() still works because it walks
|
|
239
|
+
// from `_fields[0]`, which is simply `undefined` in this state.
|
|
240
|
+
|
|
223
241
|
|
|
224
242
|
|
|
225
243
|
constructor(options = {} ) {
|
|
@@ -232,9 +250,9 @@ class FormBridgeImpl {
|
|
|
232
250
|
triggerOnChange(this);
|
|
233
251
|
};
|
|
234
252
|
|
|
235
|
-
const
|
|
236
|
-
|
|
237
|
-
this._items = formItemList(this,
|
|
253
|
+
const fields = this._fields = Array.from(getNodeList(options));
|
|
254
|
+
addEventListeners(fields, _onChange);
|
|
255
|
+
this._items = fields.length ? formItemList(this, fields) : null;
|
|
238
256
|
|
|
239
257
|
const defaults = options.defaults;
|
|
240
258
|
if (defaults) {
|
|
@@ -294,16 +312,41 @@ class FormBridgeImpl {
|
|
|
294
312
|
}
|
|
295
313
|
|
|
296
314
|
reload() {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
315
|
+
// Detach handlers from the previous snapshot first — `getNodeList()`
|
|
316
|
+
// can now legitimately return fewer (or zero) controls, and any
|
|
317
|
+
// node that drops out of the selector must stop firing this
|
|
318
|
+
// field's `change` listener.
|
|
319
|
+
removeEventListeners(this._fields, this._onChange);
|
|
320
|
+
const fields = this._fields = Array.from(getNodeList(this.options));
|
|
321
|
+
addEventListeners(fields, this._onChange);
|
|
322
|
+
this._items = fields.length ? formItemList(this, fields) : null;
|
|
301
323
|
}
|
|
302
324
|
|
|
303
325
|
items() {
|
|
326
|
+
if (!this._items) {
|
|
327
|
+
throw new Error(`Not found: name=${JSON.stringify(this.name)}`)
|
|
328
|
+
}
|
|
304
329
|
return this._items
|
|
305
330
|
}
|
|
306
331
|
|
|
332
|
+
/**
|
|
333
|
+
* Walk up from the field's first element (inclusive) and return the
|
|
334
|
+
* closest ancestor matching the selector. Useful for reaching the
|
|
335
|
+
* container element when the field is a `<select>` (so you can
|
|
336
|
+
* append `<option>` elements before calling `reload()`) or when
|
|
337
|
+
* the field is part of a checkbox/radio group inside a wrapper.
|
|
338
|
+
*
|
|
339
|
+
* For multi-element fields (checkbox/radio groups), the walk starts
|
|
340
|
+
* from the first element only — callers that need a different
|
|
341
|
+
* starting point can use `items().at(i)?.node.closest(selector)`.
|
|
342
|
+
*
|
|
343
|
+
* Returns `null` when no ancestor matches, matching the contract
|
|
344
|
+
* of `Element.closest()`.
|
|
345
|
+
*/
|
|
346
|
+
closest(selector) {
|
|
347
|
+
return _nullishCoalesce(_optionalChain([this, 'access', _ => _._fields, 'access', _2 => _2[0], 'optionalAccess', _3 => _3.closest, 'call', _4 => _4(selector)]), () => ( null))
|
|
348
|
+
}
|
|
349
|
+
|
|
307
350
|
toggle(value, checked) {
|
|
308
351
|
let result;
|
|
309
352
|
const delim = this.options.delim || DELIM;
|
package/package.json
CHANGED
|
@@ -1,47 +1,52 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "html-form-field",
|
|
3
3
|
"description": "Unified interface for HTML form fields with synchronized binding to object properties",
|
|
4
|
-
"version": "0.
|
|
5
|
-
"author": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
8
|
-
"text",
|
|
9
|
-
"lcov"
|
|
10
|
-
],
|
|
11
|
-
"include": [
|
|
12
|
-
"src/**/*.ts"
|
|
13
|
-
],
|
|
14
|
-
"reportsDir": "coverage"
|
|
4
|
+
"version": "0.4.1",
|
|
5
|
+
"author": "@kawanet",
|
|
6
|
+
"bugs": {
|
|
7
|
+
"url": "https://github.com/kawanet/html-form-field/issues"
|
|
15
8
|
},
|
|
9
|
+
"contributors": [
|
|
10
|
+
"kawanet <u-suke@kawa.net>"
|
|
11
|
+
],
|
|
16
12
|
"devDependencies": {
|
|
17
13
|
"@rollup/plugin-alias": "^5.1.1",
|
|
14
|
+
"@rollup/plugin-commonjs": "^28.0.6",
|
|
18
15
|
"@rollup/plugin-multi-entry": "^7.1.0",
|
|
19
16
|
"@rollup/plugin-node-resolve": "^16.0.3",
|
|
20
|
-
"@rollup/plugin-sucrase": "^5.0
|
|
17
|
+
"@rollup/plugin-sucrase": "^5.1.0",
|
|
21
18
|
"@rollup/plugin-terser": "^0.4.4",
|
|
22
|
-
"@types/jsdom": "^
|
|
19
|
+
"@types/jsdom": "^28.0.3",
|
|
23
20
|
"@types/node": "^24.9.1",
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"
|
|
29
|
-
"rollup": "^4.52.5",
|
|
30
|
-
"terser": "^5.44.0",
|
|
21
|
+
"html-ele": "^0.1.1",
|
|
22
|
+
"jsdom": "^29.1.1",
|
|
23
|
+
"mocha": "^11.7.6",
|
|
24
|
+
"rollup": "^4.60.4",
|
|
25
|
+
"terser": "^5.48.0",
|
|
31
26
|
"typescript": "^5.9.3"
|
|
32
27
|
},
|
|
28
|
+
"devEngines": {
|
|
29
|
+
"runtime": {
|
|
30
|
+
"name": "node",
|
|
31
|
+
"version": ">=22",
|
|
32
|
+
"onFail": "warn"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"engines": {
|
|
36
|
+
"node": ">=20"
|
|
37
|
+
},
|
|
33
38
|
"exports": {
|
|
34
39
|
".": {
|
|
40
|
+
"types": "./types/html-form-field.d.ts",
|
|
35
41
|
"require": "./dist/html-form-field.cjs",
|
|
36
|
-
"import":
|
|
37
|
-
"types": "./types/html-form-field.d.ts",
|
|
38
|
-
"default": "./dist/html-form-field.mjs"
|
|
39
|
-
}
|
|
42
|
+
"import": "./dist/html-form-field.mjs"
|
|
40
43
|
}
|
|
41
44
|
},
|
|
42
45
|
"files": [
|
|
43
46
|
"LICENSE",
|
|
44
47
|
"README.md",
|
|
48
|
+
"browser/import.js",
|
|
49
|
+
"browser/package.json",
|
|
45
50
|
"dist/html-form-field.cjs",
|
|
46
51
|
"dist/html-form-field.min.js",
|
|
47
52
|
"dist/html-form-field.mjs",
|
|
@@ -49,16 +54,30 @@
|
|
|
49
54
|
"src/*.ts",
|
|
50
55
|
"types/*.d.ts"
|
|
51
56
|
],
|
|
57
|
+
"homepage": "https://github.com/kawanet/html-form-field",
|
|
58
|
+
"keywords": [
|
|
59
|
+
"checkbox",
|
|
60
|
+
"form",
|
|
61
|
+
"html",
|
|
62
|
+
"input",
|
|
63
|
+
"radio",
|
|
64
|
+
"select",
|
|
65
|
+
"textarea"
|
|
66
|
+
],
|
|
52
67
|
"license": "MIT",
|
|
53
|
-
"main": "./
|
|
68
|
+
"main": "./dist/html-form-field.cjs",
|
|
69
|
+
"repository": {
|
|
70
|
+
"type": "git",
|
|
71
|
+
"url": "git+https://github.com/kawanet/html-form-field.git"
|
|
72
|
+
},
|
|
54
73
|
"scripts": {
|
|
55
74
|
"build": "make -C builder",
|
|
56
75
|
"fixpack": "fixpack",
|
|
57
|
-
"
|
|
58
|
-
"
|
|
59
|
-
"test
|
|
76
|
+
"prepack": "make -C builder is-buildable",
|
|
77
|
+
"prepare": "make -C builder is-not-buildable 2> /dev/null || make -C builder clean all test",
|
|
78
|
+
"test": "make -C builder test"
|
|
60
79
|
},
|
|
61
80
|
"sideEffects": false,
|
|
62
81
|
"type": "module",
|
|
63
|
-
"types": "types/html-form-field.d.ts"
|
|
82
|
+
"types": "./types/html-form-field.d.ts"
|
|
64
83
|
}
|
package/src/form-field.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type {formField as NS, FormField, FormFieldOptions} from "
|
|
1
|
+
import type {formField as NS, FormField, FormFieldOptions} from "html-form-field"
|
|
2
2
|
import {formItemList} from "./form-item.ts"
|
|
3
3
|
|
|
4
4
|
type FieldEventHandler = (this: NS.FieldElement, ev: Event) => void
|
|
@@ -26,20 +26,29 @@ const getNodeList = <T>({form, name}: {form: ParentNode, name: NS.StringKeys<T>}
|
|
|
26
26
|
const selector = `input[name=${safeName}], textarea[name=${safeName}], button[name=${safeName}], select[name=${safeName}]`
|
|
27
27
|
|
|
28
28
|
const nodeList = form.querySelectorAll<NS.FieldElement>(selector)
|
|
29
|
-
if (!nodeList.length) {
|
|
30
|
-
|
|
31
|
-
return [form] as NS.FieldElement[]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
throw new Error(`Not found: name=${safeName}`)
|
|
29
|
+
if (!nodeList.length && isHTMLElement(form) && form.matches(selector)) {
|
|
30
|
+
return [form] as NS.FieldElement[]
|
|
35
31
|
}
|
|
36
32
|
|
|
33
|
+
// No throw on a 0-match result here: a freshly rendered form may not yet
|
|
34
|
+
// contain the controls (dynamic radio/checkbox groups, lazy-populated
|
|
35
|
+
// <select>, etc.). The error surfaces lazily from `items()` instead.
|
|
37
36
|
return nodeList
|
|
38
37
|
}
|
|
39
38
|
|
|
40
|
-
|
|
39
|
+
// Detach `change` listeners from elements that were part of the previous
|
|
40
|
+
// `_fields` snapshot. Pair this with `addEventListeners` on the new
|
|
41
|
+
// snapshot inside `reload()` so a control that dropped out of the
|
|
42
|
+
// selector (renamed, removed from the DOM, or moved out of the form)
|
|
43
|
+
// stops firing this field's handlers.
|
|
44
|
+
const removeEventListeners = (nodeList: Iterable<NS.FieldElement>, handler: FieldEventHandler) => {
|
|
41
45
|
for (const node of nodeList) {
|
|
42
46
|
node.removeEventListener("change", handler)
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const addEventListeners = (nodeList: Iterable<NS.FieldElement>, handler: FieldEventHandler) => {
|
|
51
|
+
for (const node of nodeList) {
|
|
43
52
|
node.addEventListener("change", handler)
|
|
44
53
|
}
|
|
45
54
|
}
|
|
@@ -77,7 +86,12 @@ class FormBridgeImpl<T = any> implements FormField<T> {
|
|
|
77
86
|
readonly options: FormFieldOptions<T>
|
|
78
87
|
readonly name: FormField<T>["name"]
|
|
79
88
|
|
|
80
|
-
private
|
|
89
|
+
private _fields: NS.FieldElement[]
|
|
90
|
+
// `null` when the most recent getNodeList() returned zero matches.
|
|
91
|
+
// Methods that need item-level access (items, value, current, etc.)
|
|
92
|
+
// surface the error lazily; closest() still works because it walks
|
|
93
|
+
// from `_fields[0]`, which is simply `undefined` in this state.
|
|
94
|
+
private _items: NS.FormItem[] | null
|
|
81
95
|
private readonly _onChange: FieldEventHandler
|
|
82
96
|
|
|
83
97
|
constructor(options: FormFieldOptions<T> = {} as FormFieldOptions<T>) {
|
|
@@ -90,9 +104,9 @@ class FormBridgeImpl<T = any> implements FormField<T> {
|
|
|
90
104
|
triggerOnChange(this)
|
|
91
105
|
}
|
|
92
106
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
this._items = formItemList(this,
|
|
107
|
+
const fields = this._fields = Array.from(getNodeList(options))
|
|
108
|
+
addEventListeners(fields, _onChange)
|
|
109
|
+
this._items = fields.length ? formItemList(this, fields) : null
|
|
96
110
|
|
|
97
111
|
const defaults = options.defaults
|
|
98
112
|
if (defaults) {
|
|
@@ -152,16 +166,41 @@ class FormBridgeImpl<T = any> implements FormField<T> {
|
|
|
152
166
|
}
|
|
153
167
|
|
|
154
168
|
reload(): void {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
169
|
+
// Detach handlers from the previous snapshot first — `getNodeList()`
|
|
170
|
+
// can now legitimately return fewer (or zero) controls, and any
|
|
171
|
+
// node that drops out of the selector must stop firing this
|
|
172
|
+
// field's `change` listener.
|
|
173
|
+
removeEventListeners(this._fields, this._onChange)
|
|
174
|
+
const fields = this._fields = Array.from(getNodeList(this.options))
|
|
175
|
+
addEventListeners(fields, this._onChange)
|
|
176
|
+
this._items = fields.length ? formItemList(this, fields) : null
|
|
159
177
|
}
|
|
160
178
|
|
|
161
179
|
items(): NS.FormItem[] {
|
|
180
|
+
if (!this._items) {
|
|
181
|
+
throw new Error(`Not found: name=${JSON.stringify(this.name)}`)
|
|
182
|
+
}
|
|
162
183
|
return this._items
|
|
163
184
|
}
|
|
164
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Walk up from the field's first element (inclusive) and return the
|
|
188
|
+
* closest ancestor matching the selector. Useful for reaching the
|
|
189
|
+
* container element when the field is a `<select>` (so you can
|
|
190
|
+
* append `<option>` elements before calling `reload()`) or when
|
|
191
|
+
* the field is part of a checkbox/radio group inside a wrapper.
|
|
192
|
+
*
|
|
193
|
+
* For multi-element fields (checkbox/radio groups), the walk starts
|
|
194
|
+
* from the first element only — callers that need a different
|
|
195
|
+
* starting point can use `items().at(i)?.node.closest(selector)`.
|
|
196
|
+
*
|
|
197
|
+
* Returns `null` when no ancestor matches, matching the contract
|
|
198
|
+
* of `Element.closest()`.
|
|
199
|
+
*/
|
|
200
|
+
closest<E extends Element = Element>(selector: string): E | null {
|
|
201
|
+
return this._fields[0]?.closest<E>(selector) ?? null
|
|
202
|
+
}
|
|
203
|
+
|
|
165
204
|
toggle(value: string, checked?: boolean): boolean {
|
|
166
205
|
let result: boolean
|
|
167
206
|
const delim = this.options.delim || DELIM
|
package/src/form-item.ts
CHANGED
package/src/index.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
// Self-reference via the package name so `tsc --noEmit` resolves these
|
|
2
|
+
// types through `package.json` `exports` — the same path an external
|
|
3
|
+
// consumer would take. If the `exports.types` mapping ever breaks, the
|
|
4
|
+
// build fails here.
|
|
5
|
+
import type * as declared from "html-form-field"
|
|
4
6
|
|
|
5
|
-
export
|
|
7
|
+
export {formField} from "./form-field.ts"
|
|
8
|
+
export type {FormField, FormFieldOptions} from "html-form-field"
|
|
9
|
+
export type formField = typeof declared.formField
|
|
@@ -1,71 +1,83 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* html-form-field
|
|
2
|
+
* html-form-field — Unified interface for HTML form fields with two-way
|
|
3
|
+
* binding to object properties.
|
|
4
|
+
*
|
|
5
|
+
* @author kawanet
|
|
6
|
+
* @see https://github.com/kawanet/html-form-field
|
|
3
7
|
*/
|
|
8
|
+
|
|
4
9
|
declare namespace formField {
|
|
10
|
+
/** Form field elements addressable by `name=`. */
|
|
5
11
|
type FieldElement = HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement | HTMLSelectElement
|
|
6
12
|
|
|
13
|
+
/** Individual item elements managed inside a field (a checkbox/radio in a group, or an `<option>` in a `<select>`). */
|
|
7
14
|
type ItemElement = HTMLInputElement | HTMLTextAreaElement | HTMLButtonElement | HTMLOptionElement
|
|
8
15
|
|
|
9
16
|
/**
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* -
|
|
17
|
+
* Extracts the string-valued keys of `T`.
|
|
18
|
+
*
|
|
19
|
+
* - `T = undefined`: any `string` is accepted (no bound object means no key constraint).
|
|
20
|
+
* - `T` is an object: the union of keys `K` where `T[K]` extends `string`.
|
|
13
21
|
*/
|
|
14
22
|
type StringKeys<T> = [T] extends [undefined]
|
|
15
23
|
? string
|
|
16
24
|
: { [K in keyof T]: K extends string ? (T[K] extends string ? K : never) : never }[keyof T];
|
|
17
25
|
|
|
26
|
+
/** Handler invoked when a field value is written. See `FormFieldOptions.onWrite`. */
|
|
18
27
|
type OnWrite<T> = FormFieldOptions<T>["onWrite"]
|
|
19
28
|
|
|
29
|
+
/** Handler invoked on a user-driven `change` event. See `FormFieldOptions.onChange`. */
|
|
20
30
|
type OnChange<T> = FormFieldOptions<T>["onChange"]
|
|
21
31
|
|
|
22
32
|
interface FormFieldOptions<T = any> {
|
|
23
33
|
/**
|
|
24
|
-
* form element or container element
|
|
34
|
+
* The form element, or any container element that holds the field elements.
|
|
25
35
|
*/
|
|
26
36
|
form: HTMLFormElement | FieldElement | ParentNode
|
|
27
37
|
|
|
28
38
|
/**
|
|
29
|
-
* name of the field
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
39
|
+
* `name=` attribute of the field to bind.
|
|
40
|
+
*
|
|
41
|
+
* When `bindTo` is supplied, TypeScript infers `T` from that object and `name` is
|
|
42
|
+
* restricted to keys of the bound object whose value type is `string`. Without
|
|
43
|
+
* `bindTo`, any string is accepted.
|
|
33
44
|
*/
|
|
34
45
|
name: StringKeys<T>
|
|
35
46
|
|
|
36
47
|
/**
|
|
37
|
-
* Object to synchronize form
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
48
|
+
* Object to synchronize with the form field.
|
|
49
|
+
*
|
|
50
|
+
* A getter/setter is installed on the property matching `name`: the getter reads
|
|
51
|
+
* the live DOM value, the setter writes back to the DOM and fires `onWrite`. Other
|
|
52
|
+
* properties on the object are left untouched.
|
|
42
53
|
*/
|
|
43
54
|
bindTo?: T
|
|
44
55
|
|
|
45
56
|
/**
|
|
46
|
-
* Called
|
|
47
|
-
* or
|
|
48
|
-
* Fires before onChange
|
|
57
|
+
* Called whenever the field value is written — either by a setter
|
|
58
|
+
* (`field.value = ...` or assignment to a bound property) or by a user-driven
|
|
59
|
+
* `change` event. Fires before `onChange`.
|
|
49
60
|
*/
|
|
50
61
|
onWrite?: (field: FormField<T>) => void
|
|
51
62
|
|
|
52
63
|
/**
|
|
53
|
-
* Called
|
|
54
|
-
*
|
|
64
|
+
* Called only on a user-driven `change` event. Setter assignments do not trigger
|
|
65
|
+
* this handler. Fires after `onWrite`.
|
|
55
66
|
*/
|
|
56
67
|
onChange?: (item: FormField<T>) => void
|
|
57
68
|
|
|
58
69
|
/**
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
70
|
+
* Delimiter joining multiple values for checkbox groups and multi-select fields.
|
|
71
|
+
* Defaults to `,` (comma). Use Unit Separator (`\x1F`) if your values may contain
|
|
72
|
+
* commas.
|
|
62
73
|
*/
|
|
63
74
|
delim?: string
|
|
64
75
|
|
|
65
76
|
/**
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
77
|
+
* Candidate initial values, tried in order.
|
|
78
|
+
*
|
|
79
|
+
* For checkbox / radio / select fields, the first value with a matching option is
|
|
80
|
+
* applied; if none matches, the next candidate is tried, and so on.
|
|
69
81
|
*/
|
|
70
82
|
defaults?: string[]
|
|
71
83
|
}
|
|
@@ -73,108 +85,110 @@ declare namespace formField {
|
|
|
73
85
|
interface FormField<T = any> {
|
|
74
86
|
readonly options: FormFieldOptions<T>
|
|
75
87
|
|
|
76
|
-
/**
|
|
77
|
-
* name of the field element (input, select, textarea)
|
|
78
|
-
*/
|
|
88
|
+
/** `name=` attribute of the bound field. */
|
|
79
89
|
readonly name: StringKeys<T>
|
|
80
90
|
|
|
81
91
|
/**
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
92
|
+
* Getter/setter for the field value. Assignment fires the `onWrite` handler.
|
|
93
|
+
*
|
|
94
|
+
* For checkbox groups and multi-select fields the value is exposed as a single
|
|
95
|
+
* string formed by joining the selected values with `options.delim` (default `,`).
|
|
86
96
|
*/
|
|
87
97
|
value: string
|
|
88
98
|
|
|
89
99
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
*
|
|
100
|
+
* Re-scan the form for items matching `name` and rebind them. Call this after
|
|
101
|
+
* the underlying DOM changes — for example when checkboxes, radio buttons, or
|
|
102
|
+
* `<option>` elements are added or removed — so the `FormField` reflects the
|
|
103
|
+
* current state of the form.
|
|
94
104
|
*/
|
|
95
105
|
reload(): void
|
|
96
106
|
|
|
97
107
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
100
|
-
* -
|
|
108
|
+
* All items belonging to the field, including disabled ones.
|
|
109
|
+
*
|
|
110
|
+
* - checkbox / radio / select: every option in the group.
|
|
111
|
+
* - other field types: the single underlying item.
|
|
101
112
|
*/
|
|
102
113
|
items(): FormItem[]
|
|
103
114
|
|
|
104
115
|
/**
|
|
105
|
-
*
|
|
106
|
-
*
|
|
107
|
-
* -
|
|
108
|
-
* -
|
|
116
|
+
* Active items only — disabled items are excluded.
|
|
117
|
+
*
|
|
118
|
+
* - checkbox / radio: items that are currently checked and enabled.
|
|
119
|
+
* - select: options that are currently selected and enabled.
|
|
120
|
+
* - other field types: the single underlying item, if enabled.
|
|
109
121
|
*/
|
|
110
122
|
current(): FormItem[]
|
|
111
123
|
|
|
112
124
|
/**
|
|
113
|
-
*
|
|
125
|
+
* Toggle the selection of an item (checkbox or multi-select). With `checked`
|
|
126
|
+
* supplied, sets the state explicitly; without it, flips the current state.
|
|
127
|
+
* Returns the new selection state.
|
|
114
128
|
*/
|
|
115
129
|
toggle(value: string, checked?: boolean): boolean
|
|
116
130
|
|
|
117
|
-
/**
|
|
118
|
-
* check if an option is selected (for checkbox, multi-select)
|
|
119
|
-
*/
|
|
131
|
+
/** Returns `true` if the given value is currently selected. */
|
|
120
132
|
has(value: string): boolean
|
|
121
133
|
|
|
122
134
|
/**
|
|
123
|
-
* Shortcut
|
|
124
|
-
* Equivalent to items().at(index). Returns undefined if out of range.
|
|
135
|
+
* Shortcut for `items().at(index)`. Returns `undefined` when out of range.
|
|
125
136
|
*/
|
|
126
137
|
itemAt(index: number): FormItem | undefined
|
|
127
138
|
|
|
128
139
|
/**
|
|
129
|
-
* Shortcut
|
|
130
|
-
*
|
|
140
|
+
* Shortcut for `items().find(v => v.value === value)`. Returns the first match,
|
|
141
|
+
* or `undefined` if no item has the given value.
|
|
131
142
|
*/
|
|
132
143
|
itemOf(value: string): FormItem | undefined
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Walk up from the field's first element (inclusive) and return the closest
|
|
147
|
+
* ancestor that matches `selector`, mirroring `Element.closest()`.
|
|
148
|
+
*
|
|
149
|
+
* The primary use case is reaching the container element so you can mutate
|
|
150
|
+
* the DOM and then call `reload()`. For example, to populate an initially
|
|
151
|
+
* empty `<select>`:
|
|
152
|
+
*
|
|
153
|
+
* ```ts
|
|
154
|
+
* const cityField = formField({form, name: "city"})
|
|
155
|
+
* const select = cityField.closest<HTMLSelectElement>("select")
|
|
156
|
+
* select.add(new Option("Tokyo", "tokyo"))
|
|
157
|
+
* cityField.reload()
|
|
158
|
+
* ```
|
|
159
|
+
*
|
|
160
|
+
* For multi-element fields (checkbox/radio groups), the walk starts from
|
|
161
|
+
* the first element only. Returns `null` if no ancestor matches.
|
|
162
|
+
*/
|
|
163
|
+
closest<E extends Element = Element>(selector: string): E | null
|
|
133
164
|
}
|
|
134
165
|
|
|
135
166
|
interface FormItem<E extends ItemElement = ItemElement> {
|
|
136
|
-
/**
|
|
137
|
-
* underlying HTML element (input, option, textarea)
|
|
138
|
-
*/
|
|
167
|
+
/** Underlying HTML element (`<input>`, `<option>`, `<textarea>`, ...). */
|
|
139
168
|
readonly node: E
|
|
140
169
|
|
|
141
|
-
/**
|
|
142
|
-
* getter/setter of the `value` property
|
|
143
|
-
* setter triggers onWrite handler
|
|
144
|
-
*/
|
|
170
|
+
/** Getter/setter for the item's `value` property. Assignment fires `onWrite`. */
|
|
145
171
|
value: string
|
|
146
172
|
|
|
147
|
-
/**
|
|
148
|
-
* method to set the `value` property silently
|
|
149
|
-
* does not trigger onWrite handler
|
|
150
|
-
*/
|
|
173
|
+
/** Set `value` without firing `onWrite`. */
|
|
151
174
|
setValue(value: string): void
|
|
152
175
|
|
|
153
|
-
/**
|
|
154
|
-
* whether the option is checkable (radio, checkbox, select)
|
|
155
|
-
*/
|
|
176
|
+
/** Whether the item is a checkable type (radio, checkbox, or `<option>`). */
|
|
156
177
|
readonly checkable: boolean
|
|
157
178
|
|
|
158
179
|
/**
|
|
159
|
-
*
|
|
160
|
-
*
|
|
180
|
+
* Selection state for radio buttons, checkboxes, and `<option>` elements.
|
|
181
|
+
* Assignment fires `onWrite`.
|
|
161
182
|
*/
|
|
162
183
|
checked: boolean | undefined
|
|
163
184
|
|
|
164
|
-
/**
|
|
165
|
-
* method to set the `checked` property silently
|
|
166
|
-
* does not trigger onWrite handler
|
|
167
|
-
*/
|
|
185
|
+
/** Set `checked` without firing `onWrite`. */
|
|
168
186
|
setChecked(checked: boolean): void
|
|
169
187
|
|
|
170
|
-
/**
|
|
171
|
-
* getter/setter of the `disabled` property
|
|
172
|
-
*/
|
|
188
|
+
/** Getter/setter for the `disabled` property. */
|
|
173
189
|
disabled: boolean
|
|
174
190
|
|
|
175
|
-
/**
|
|
176
|
-
* text content of the option (if applicable)
|
|
177
|
-
*/
|
|
191
|
+
/** Visible label text of the item, when the underlying element exposes one. */
|
|
178
192
|
readonly label: string | undefined
|
|
179
193
|
}
|
|
180
194
|
}
|
|
@@ -183,4 +197,13 @@ export type FormField<T = any> = formField.FormField<T>
|
|
|
183
197
|
|
|
184
198
|
export type FormFieldOptions<T = any> = formField.FormFieldOptions<T>
|
|
185
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Construct a `FormField` bound to the form control(s) with the given `name`.
|
|
202
|
+
*
|
|
203
|
+
* Construction itself does not throw when zero controls match — the resulting
|
|
204
|
+
* field can still call `closest()` to locate the surrounding container so the
|
|
205
|
+
* caller can populate a dynamic group and then `reload()`. Methods that
|
|
206
|
+
* actually need an item (`items()`, `value`, `current()`, `toggle()`, …) throw
|
|
207
|
+
* `Not found: name=...` lazily until at least one control is matched.
|
|
208
|
+
*/
|
|
186
209
|
export const formField: <T = any>(options: FormFieldOptions<T>) => FormField<T>
|