A widget is a third-party control shown in place of a field's html element, such as select2, Choices.js, Tom Select or a date picker. It keeps the html element in sync, but has its own way of being read, written and listened to.
DynamicForms integrates a widget through the Field Hooks of its field. The field is still the html element with the name (the <select> or <input> the widget decorates or hides), so the widget is created on that element before makeForm.
A Field Hook which replaces a Standard Behaviour receives it as its last parameter, standard. When the widget only needs to read again what the library wrote, run the standard and refresh it:
123456
select:{saveData:(htmlElement,data,standard)=>{standard();// writes the <option>s as usual$(htmlElement).selectpicker('refresh');},}
Many widgets fire change when written by code. While the library writes to a field (io.set, behavior.clear, select.saveData), it ignores the field's changes, so they don't start a second update of its Observers, which the library already updates. The events still reach the widget and your code.
This covers events fired during the write. A widget which reacts later (after a timeout or a microtask) and then fires change still starts an update: pass it the option that makes it silent.
A widget's Field Hooks are the same for every field which uses it: write them once in a function returning the field configuration. The recipes below take the field name and the overrides of that field, and merge them section by section with mergeField, so that { fetch: { makeUrl } } adds to the widget's hooks instead of replacing a whole section:
1 2 3 4 5 6 7 8 910111213
/** * Merges a Field Configuration with overrides section by section: in `io`, `fetch`, `behavior`, `select` * and `checkbox` the overrides replace only the keys they have, where a plain spread would replace the whole section. */exportfunctionmergeField(field,overrides={}){constmerged={...field,...overrides};for(constsectionof['io','fetch','behavior','select','checkbox']){if(field[section]||overrides[section]){merged[section]={...field[section],...overrides[section]};}}returnmerged;}
import{mergeField}from'./merge-field.js';/** * A select Field shown as a select2 Widget (https://select2.org), created before makeForm with `$(select).select2()`. * - select2 announces changes only through jQuery events, which addEventListener never hears: io.listen listens with jQuery. * - It re-reads the <option>s the library writes by itself, but shows a new Value only after a jQuery `change`. */exportfunctionselect2Field(name,overrides={}){returnmergeField({name,io:{listen:(htmlElement,onChange)=>{$(htmlElement).on('change.dynamicForms',onChange);return()=>$(htmlElement).off('change.dynamicForms');},set:(htmlElement,value,standard)=>{standard();$(htmlElement).trigger('change');},},behavior:{clear:(htmlElement,standard)=>{standard();$(htmlElement).trigger('change');},},},overrides);}
import{mergeField}from'./merge-field.js';/** * A select Field shown as a Choices.js Widget (https://choices-js.github.io/Choices/), created before makeForm with `new Choices(select)`. * - Its instance can't be reached from the <select>: pass it, and the hooks keep it in their closure. * - Choices dispatches a DOM `change` on the <select>, which the Standard Behaviour of io.listen hears. * - It doesn't notice the <option>s the library writes: after writing them, `refresh` reads them again. */exportfunctionchoicesField(name,choices,overrides={}){returnmergeField({name,io:{// With no choice made, the <select> value would be its first optionget:()=>choices.getValue(true)??'',set:(_htmlElement,value)=>{choices.setChoiceByValue(value);},},behavior:{clear:(_htmlElement,standard)=>{standard();choices.refresh(false,false,true);},},select:{saveData:(_htmlElement,_options,standard)=>{standard();choices.refresh(false,false,true);},},},overrides);}
import{mergeField}from'./merge-field.js';/** * A select Field shown as a Tom Select Widget (https://tom-select.js.org), created before makeForm with `new TomSelect(select)`. * - Tom Select dispatches DOM `change` events on the <select>, which the Standard Behaviour of io.listen hears. * - It keeps its own list of Options: they are written through its api, not as <option>s. * - Its writes fire `change`: the library ignores the changes of a field while it writes it. */exportfunctiontomSelectField(name,overrides={}){returnmergeField({name,io:{set:(htmlElement,value)=>{htmlElement[0].tomselect.setValue(value);},},behavior:{clear:(htmlElement)=>{consttomSelect=htmlElement[0].tomselect;tomSelect.clear();tomSelect.clearOptions();},},select:{saveData:(htmlElement,options)=>{consttomSelect=htmlElement[0].tomselect;tomSelect.clear();tomSelect.clearOptions();tomSelect.addOptions(options);// Options are { value, text }, Tom Select's default valueField and labelField},},},overrides);}
Not tested by the library: bootstrap-select 1.13 needs jQuery 3 or older.
import{mergeField}from'./merge-field.js';/** * A select Field shown as a bootstrap-select Widget (https://developer.snapappointments.com/bootstrap-select/), * created before makeForm with `$(select).selectpicker()`. bootstrap-select 1.13 needs jQuery 3 or older. * - It dispatches DOM `change` events on the <select>, which the Standard Behaviour of io.listen hears. * - It doesn't notice what the library writes in the <select>: after each write, `refresh` reads it again. */exportfunctionbootstrapSelectField(name,overrides={}){constrefresh=(htmlElement)=>$(htmlElement).selectpicker('refresh');returnmergeField({name,io:{set:(htmlElement,_value,standard)=>{standard();refresh(htmlElement);},},behavior:{clear:(htmlElement,standard)=>{standard();refresh(htmlElement);},},select:{saveData:(htmlElement,_options,standard)=>{standard();refresh(htmlElement);},},},overrides);}
Tested with flatpickr 4.6.
1 2 3 4 5 6 7 8 91011121314151617181920212223
import{mergeField}from'./merge-field.js';/** * An input Field shown as a flatpickr Widget (https://flatpickr.js.org), created before makeForm with `flatpickr(input)`. * - flatpickr keeps the input value in sync and dispatches DOM `change` events: reading and listening need no hook. * - A value written in the input isn't shown in the calendar: writing and clearing go through its instance. * - Its clear() fires `change`: the library ignores the changes of a field while it writes it. */exportfunctionflatpickrField(name,overrides={}){returnmergeField({name,io:{set:(htmlElement,value)=>{htmlElement[0]._flatpickr.setDate(value);},},behavior:{clear:(htmlElement)=>{htmlElement[0]._flatpickr.clear();},},},overrides);}
A form-associated custom element with the field name (e.g. <sl-select name="city">) is a field like the others. The library doesn't recognise it as a select, checkbox or radio, so it gets the standard behaviour of a generic input: its value is read and written, and its change is listened to.
A select-like custom element doesn't get the select's options loading: there's no remote call through fetch, postProcessData, saveData or clearOnParentVoid. Load and write its options in behavior.updateStatus, passing signal to the remote call.
In TypeScript, state the element type of the field:
1 2 3 4 5 6 7 8 9101112
importtype{FieldConfiguration}from'@simomosi/dynamic-forms';// SlSelect: the class of the custom element, imported from its libraryconstcity:FieldConfiguration<SlSelect,string>={name:'city',behavior:{updateStatus:async(htmlElement,data,subjectName,signal)=>{constcities:{id:string,name:string}[]=await(awaitfetch(`/cities?country=${data.country}`,{signal})).json();htmlElement[0].replaceChildren(...cities.map((c)=>Object.assign(document.createElement('sl-option'),{value:c.id,textContent:c.name})));},},};