html-form-field 0.3.1 → 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.
@@ -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,7 +1,7 @@
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.3.1",
4
+ "version": "0.4.1",
5
5
  "author": "@kawanet",
6
6
  "bugs": {
7
7
  "url": "https://github.com/kawanet/html-form-field/issues"
package/src/form-field.ts CHANGED
@@ -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
@@ -141,6 +141,26 @@ declare namespace formField {
141
141
  * or `undefined` if no item has the given value.
142
142
  */
143
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
144
164
  }
145
165
 
146
166
  interface FormItem<E extends ItemElement = ItemElement> {
@@ -177,4 +197,13 @@ export type FormField<T = any> = formField.FormField<T>
177
197
 
178
198
  export type FormFieldOptions<T = any> = formField.FormFieldOptions<T>
179
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
+ */
180
209
  export const formField: <T = any>(options: FormFieldOptions<T>) => FormField<T>