Skip to content

Third-party widgets#

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.

What to write#

A widget needs a Field Hook only where the library's Standard Behaviour doesn't fit:

The widget... Field Hook
announces its changes with jQuery events or a callback, which addEventListener doesn't hear io.listen
announces them with an event other than change io.event
doesn't keep the element's value up to date io.get
doesn't show a value written in the element io.set
doesn't show an emptied element behavior.clear
doesn't show the <option>s written in the select select.saveData

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:

1
2
3
4
5
6
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.

Reusing an integration#

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
 9
10
11
12
13
/**
 * 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.
 */
export function mergeField(field, overrides = {}) {
    const merged = { ...field, ...overrides };
    for (const section of ['io', 'fetch', 'behavior', 'select', 'checkbox']) {
        if (field[section] || overrides[section]) {
            merged[section] = { ...field[section], ...overrides[section] };
        }
    }
    return merged;
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
import { select2Field } from './select2.js';

$('select[name="country"], select[name="city"]').select2();
const form = makeForm({
    id: 'address',
    fields: [
        select2Field('country'),
        select2Field('city', { fetch: { makeUrl: (data) => `/cities?country=${data.country}` } }),
    ],
    rules: [{ name: 'country', update: ['city'] }],
});

When the widget's instance can't be reached from the html element, pass it to the function, and the hooks keep it (see Choices.js below).

Recipes#

Copy and adapt them. They are tested against the widget versions in the notes, except bootstrap-select.

Tested with select2 4.1 and jQuery 4.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
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`.
 */
export function select2Field(name, overrides = {}) {
    return mergeField({
        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);
}

Tested with Choices.js 11.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.
 */
export function choicesField(name, choices, overrides = {}) {
    return mergeField({
        name,
        io: {
            // With no choice made, the <select> value would be its first option
            get: () => 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);
}
1
2
const city = new Choices('select[name="city"]');
makeForm({ id: 'address', fields: [choicesField('city', city, { fetch: { makeUrl } })], /* ... */ });

Tested with Tom Select 2.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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.
 */
export function tomSelectField(name, overrides = {}) {
    return mergeField({
        name,
        io: {
            set: (htmlElement, value) => {
                htmlElement[0].tomselect.setValue(value);
            },
        },
        behavior: {
            clear: (htmlElement) => {
                const tomSelect = htmlElement[0].tomselect;
                tomSelect.clear();
                tomSelect.clearOptions();
            },
        },
        select: {
            saveData: (htmlElement, options) => {
                const tomSelect = 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.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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.
 */
export function bootstrapSelectField(name, overrides = {}) {
    const refresh = (htmlElement) => $(htmlElement).selectpicker('refresh');
    return mergeField({
        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
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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.
 */
export function flatpickrField(name, overrides = {}) {
    return mergeField({
        name,
        io: {
            set: (htmlElement, value) => {
                htmlElement[0]._flatpickr.setDate(value);
            },
        },
        behavior: {
            clear: (htmlElement) => {
                htmlElement[0]._flatpickr.clear();
            },
        },
    }, overrides);
}

Custom elements#

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
 9
10
11
12
import type { FieldConfiguration } from '@simomosi/dynamic-forms';
// SlSelect: the class of the custom element, imported from its library

const city: FieldConfiguration<SlSelect, string> = {
    name: 'city',
    behavior: {
        updateStatus: async (htmlElement, data, subjectName, signal) => {
            const cities: { id: string, name: string }[] = await (await fetch(`/cities?country=${data.country}`, { signal })).json();
            htmlElement[0].replaceChildren(...cities.map((c) => Object.assign(document.createElement('sl-option'), { value: c.id, textContent: c.name })));
        },
    },
};