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 CHANGED
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 Yusuke Kawasaki
3
+ Copyright (c) 2025-2026 Yusuke Kawasaki
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
package/README.md CHANGED
@@ -1,16 +1,16 @@
1
1
  # html-form-field
2
2
 
3
- Unified interface for HTML form fields with synchronized binding to object properties
3
+ Unified interface for HTML form fields with two-way binding to object properties.
4
4
 
5
- [![Node.js CI](https://github.com/kawanet/html-form-field/workflows/Node.js%20CI/badge.svg)](https://github.com/kawanet/html-form-field/actions/)
5
+ [![Node.js CI](https://github.com/kawanet/html-form-field/actions/workflows/nodejs.yml/badge.svg?branch=main)](https://github.com/kawanet/html-form-field/actions/)
6
6
  [![npm version](https://img.shields.io/npm/v/html-form-field)](https://www.npmjs.com/package/html-form-field)
7
7
  [![gzip size](https://img.badgesize.io/https://cdn.jsdelivr.net/npm/html-form-field/dist/html-form-field.min.js?compression=gzip)](https://cdn.jsdelivr.net/npm/html-form-field/dist/html-form-field.min.js)
8
8
 
9
- - Unified getter/setter for text inputs, checkboxes, radio buttons, and select elements
10
- - Two-way binding between form fields and object properties (synchronized updates in both directions)
11
- - Built-in change detection with `onChange` and `onWrite` callbacks
12
- - Small browser build: [html-form-field.min.js](https://cdn.jsdelivr.net/npm/html-form-field/dist/html-form-field.min.js) under 4KB minified, under 2KB gzipped
13
- - Full TypeScript support - [html-form-field.d.ts](https://github.com/kawanet/html-form-field/blob/main/types/html-form-field.d.ts) for detailed specifications
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" // updates form field
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) // current value
58
+ console.log(email.value) // read the current value
60
59
 
61
- email.value = "john@example.com" // update value
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") // toggle checkbox
70
-
71
- favo.toggle("travel", true)
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.toggle("trading", false)
72
+ console.log(favo.has("travel")) // is this option currently selected?
74
73
 
75
- console.log(favo.has("travel")) // check if selected
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 to item by value. Equivalent to items().find(v => v.value === value)
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
- ## LINKS
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,2 @@
1
+ /* globals formField */
2
+ exports.formField = formField;
@@ -0,0 +1 @@
1
+ {"type":"commonjs"}
@@ -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
- if (isHTMLElement(form) && form.matches(selector)) {
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
- const updateEventListener = (nodeList, handler) => {
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 nodeList = getNodeList(options);
238
- updateEventListener(nodeList, _onChange);
239
- this._items = formItemList(this, nodeList);
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
- const nodeList = getNodeList(this.options);
300
- const _onChange = this._onChange;
301
- updateEventListener(nodeList, _onChange);
302
- this._items = formItemList(this, nodeList);
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 c(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 c 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 l=e=>"string"==typeof e,h=e=>null==e?"":l(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);if(!i.length){if("function"==typeof e.matches&&e.matches(n))return[e];throw new Error(`Not found: name=${s}`)}return i},d=(e,t)=>{for(const s of e)s.removeEventListener("change",t),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=u(e);d(n,s),this._items=i(this,n);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&&l(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(){const e=u(this.options),t=this._onChange;d(e,t),this._items=i(this,e)}items(){return this._items}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}({});
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}({});
@@ -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
- if (isHTMLElement(form) && form.matches(selector)) {
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
- const updateEventListener = (nodeList, handler) => {
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 nodeList = getNodeList(options);
236
- updateEventListener(nodeList, _onChange);
237
- this._items = formItemList(this, nodeList);
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
- const nodeList = getNodeList(this.options);
298
- const _onChange = this._onChange;
299
- updateEventListener(nodeList, _onChange);
300
- this._items = formItemList(this, nodeList);
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.2.0",
5
- "author": "Kawanet",
6
- "c8": {
7
- "reporter": [
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.2",
17
+ "@rollup/plugin-sucrase": "^5.1.0",
21
18
  "@rollup/plugin-terser": "^0.4.4",
22
- "@types/jsdom": "^27.0.0",
19
+ "@types/jsdom": "^28.0.3",
23
20
  "@types/node": "^24.9.1",
24
- "c8": "^10.1.3",
25
- "chai": "^6.2.0",
26
- "html-ele": "^0.0.1",
27
- "jsdom": "^27.0.1",
28
- "mocha": "^11.7.4",
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": "./src/index.ts",
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
- "prepare": "make -C builder clean all test",
58
- "test": "node --test",
59
- "test:coverage": "c8 node --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 "../types/html-form-field.d.ts"
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
- if (isHTMLElement(form) && form.matches(selector)) {
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
- const updateEventListener = (nodeList: Iterable<NS.FieldElement>, handler: FieldEventHandler) => {
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 _items: NS.FormItem[]
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 nodeList = getNodeList(options)
94
- updateEventListener(nodeList, _onChange)
95
- this._items = formItemList(this, nodeList)
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
- const nodeList = getNodeList(this.options)
156
- const _onChange = this._onChange
157
- updateEventListener(nodeList, _onChange)
158
- this._items = formItemList(this, nodeList)
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
@@ -1,4 +1,4 @@
1
- import type {FormField, formField as NS} from "../types/html-form-field.d.ts"
1
+ import type {FormField, formField as NS} from "html-form-field"
2
2
 
3
3
  const triggerOnWrite = (field: FormField) => {
4
4
  const onWrite = field.options.onWrite
package/src/index.ts CHANGED
@@ -1,5 +1,9 @@
1
- export {formField} from "./form-field.ts"
2
- export type {FormField, FormFieldOptions} from "../types/html-form-field.d.ts"
3
- import type {formField as NS} from "../types/html-form-field.d.ts"
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 type formField = typeof NS
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 - Unified interface for HTML form fields with synchronized binding to object properties
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
- * Extract string keys of T
11
- * - If T is undefined: string (allow any name)
12
- * - If T is an object: union of keys K where T[K] extends string (strict)
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 which includes field elements
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 element
30
- * - When `bindTo` is supplied, TypeScript infers `T` from that object and `name` is restricted
31
- * to keys of the bound object whose value type is `string`.
32
- * - When `bindTo` is not supplied (or `T` is not inferred), any string is allowed.
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 values.
38
- * Only properties matching field names obtained via item() will have getters/setters defined.
39
- * The getter always references the value of the DOM field,
40
- * and the setter updates the DOM and triggers onWrite.
41
- * Other properties are not affected.
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 when the value is changed by user interaction,
47
- * or when assigning to a bound object's property.
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 when the value is changed by user interaction (change event).
54
- * Not triggered by setter assignments. Fires after onWrite.
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
- * delimiter for multiple values (for checkboxes, multi-select)
60
- * Default: `,` (comma). Use Unit Separator `\x1F` instead,
61
- * if your values may include commas to reduce collisions.
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
- * Default values to attempt at initialization (in order).
67
- * For checkbox/radio/select fields, tries the first value; if no matching option exists,
68
- * falls back to the next value, and so on until a match is found.
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
- * getter/setter of the value.
83
- * setter triggers onWrite handler.
84
- * Note: even for checkbox groups / multiple-select, the exposed type is string.
85
- * Multiple values are represented as a single string joined by options.delim (default ',').
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
- * rebind the field element (input, select, textarea).
91
- * Call this when the set of option elements changes (for example when checkboxes,
92
- * radio buttons, or select <option> elements are added or removed) so that the
93
- * FormField re-scans and rebinds its items to reflect the current DOM.
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
- * list of all items (including disabled items)
99
- * - for checkbox, radio, select: returns all options
100
- * - for other types: returns all item(s)
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
- * list of active items (excluding disabled items)
106
- * - for checkbox, radio: returns checked and not-disabled items
107
- * - for select: returns selected and not-disabled options
108
- * - for other types: returns all not-disabled item(s)
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
- * select/deselect an option (for checkbox, multi-select)
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 to access an item by index (operates on items()).
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 to access an item by value (operates on items()).
130
- * Returns the first FormItem whose value equals the given value, or undefined if not found.
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
- * whether the option is selected (radio, checkbox, select)
160
- * setter triggers onWrite handler
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>