Lattice Grid Buy a licence

blog

JavaScript form library for data apps: the Lattice form

Building…
Loading a live grid…

Open the customer screen of almost any back office and you will find two libraries doing one job. The grid shows the records. A second form library edits them. It does not know which row you selected, how tall the rows are, which columns are read-only, or what the grid does when a save is refused, so you write that glue yourself, screen after screen, and keep it in step every time a column changes.

The Lattice form is the other half of the grid. It is a JavaScript form library for data apps, built to sit beside the grid and edit what the grid shows. It has shipped across several releases. This is the first time we have put it in one place, with a working example for each part.

Describe the fields, get the form

A form is one JSON definition. Rows stack, cols sit side by side, fieldsets group, and a width in percent says how much of the row an item takes. Density is compact, standard or comfortable, and a form beside a grid follows that grid’s row height, so the two look like one screen. The definition is below, and the live form follows it. Change the density and every control follows with no redraw.

const form = LatticeGridForm.createForm(document.querySelector('#customer'), {
  rows: [
    { fieldset: 'Customer', cols: [
      { type: 'input', name: 'name', label: 'Name', width: '50%' },
      { type: 'input', name: 'email', label: 'Billing email', width: '50%' },
    ] },
    { fieldset: 'Account', cols: [
      { type: 'combo', name: 'owner', label: 'Owner', options: owners, width: '33.3%' },
      { type: 'combo', name: 'plan', label: 'Plan', options: plans, width: '33.3%' },
      { type: 'number', name: 'limit', label: 'Credit limit',
        format: { style: 'currency', currency: 'USD' }, width: '33.3%' },
    ] },
  ],
});
form.setDensity('comfortable');
// owners: [{ value: 'maya', label: 'Maya Okafor', avatar: '/img/maya.svg' }, ...]
// plans:  [{ value: 'growth', label: 'Growth', description: 'Up to 25 seats' }, ...]

The demo opens at comfortable density. The owner options carry an avatar URL, which the combo draws round and replaces with initials when the picture is missing, and the plan options carry a description, which becomes the second line of the list. The same record also uses a toggle group for the contact preference, a rating, a colour field, a date range, chips for tags and a stage pipeline for the deal.

Bound to the grid

Bind a form to a grid row and it fills from that row. Leave the layout out and it builds one item per column, using the column’s title, type and editor. Select a row below, change a field in the form and press Save. The grid cell changes, because the save goes through the grid’s own edit path, so its permissions, read-only columns and validation apply to the form as well. Revert puts an unsaved edit back.

Building…
Loading a live grid…
const form = createForm(el, { bind: { grid, rowKey: 'C-102' } });
form.isDirty();   // an unsaved edit, on one item or the whole form
form.save();      // writes the dirty items once, through the grid
form.revert();    // restores the bound values

If your records arrive through the data router, bind to the record instead: bind: { router, route: 'ACCOUNT', key: 'A-1042', onSave }. The form follows the feed, updates a field the user has not touched, leaves one they are typing in alone and flags it as conflicted, and writes its changes back through the router when you save.

Validation and rules that react

Mark a field required, give it a built-in rule for email, number, integer or url, a regular expression, or a function that returns the message to show. Number fields take min and max, a currency, percent or fixed-decimal format that shows while the field is not focused, and units: type 145 psi into a bar field and it stores the converted value. A rule such as visibleWhen re-runs on every change, so a field can appear, lock or become required from the value of another. Press Submit below with everything empty, then type a company name.

Building…
Loading a live grid…
{ type: 'number', name: 'seats', label: 'Seats', min: 1, max: 500, required: true,
  validation: (v) => (v >= 1 && v <= 500) || 'Choose between 1 and 500 seats' },
{ type: 'input', name: 'po', label: 'Purchase order number',
  visibleWhen: { name: 'company', empty: false } },

Failures carry aria-invalid and a message under the field, and a submit button validates first and fires submit with the values only when the form is valid.

The controls a record needs

The form page lists them all and shows the form live. A record usually needs more than text boxes:

  • A combo that pages and searches a list on the server, with avatars, icons and dots on its options
  • A date range picker with presets, limits on the span and a month-range mode
  • A list box with single, multiple and checkbox selection, a filter box and two connected lists
  • A colour field with a palette, remembered colours and typed hex entry
  • A rich text editor with a sanitised output
  • File upload with drop, paste or pick, type and size limits, and progress per file
  • An avatar field with an optional upload
  • A toggle group, exclusive or independent
  • A time field in 12 or 24 hour display
  • A rating, and a stage pipeline for a deal or a ticket
  • A one-time code entry
  • A content container that shows your own HTML or hosts a grid inside the form

Add your own custom controls

The built-in list is long, but your product has its own vocabulary: an alert level meter, a signature pad, a seat map, a status picker in your brand colours. Design that component once, register it with registerControl from the form module, and use it in any form exactly like a built-in. You declare it by type in the JSON. It gets the same label, help text, density, theme and dark mode. You read and write it with the standard form.getValue() and form.setValue(), it is validated with the form’s rules, including a sync or async validate, and it fires the same change events. Below, a five-bar level meter, drawn like a signal-strength indicator, sits beside a built-in field. Click a bar or use the arrow keys, and the readout follows getValue(). Press Submit with none chosen and the required message appears.

Building…
Loading a live form…
const NAMES = ['LOW', 'GUARDED', 'ELEVATED', 'HIGH', 'SEVERE'];
registerControl('level', {
  create(el, item, api) {
    const group = document.createElement('span');
    group.setAttribute('role', 'radiogroup');       // spans in a radiogroup, not buttons
    group.setAttribute('aria-label', item.label);
    NAMES.forEach((name, i) => {
      const bar = document.createElement('span');
      bar.setAttribute('role', 'radio');
      bar.setAttribute('aria-label', `${name}, level ${i + 1} of 5`);
      bar.style.height = `${13 + i * 5}px`;         // rising bars
      bar.onclick = () => set(el, i + 1, api);
      bar.onkeydown = (e) => {
        const step = { ArrowRight: 1, ArrowUp: 1, ArrowLeft: -1, ArrowDown: -1 }[e.key];
        if (step) set(el, Number(el.dataset.value || 0) + step, api); // and move focus
      };
      group.append(bar);
    });
    el.append(group, document.createElement('code')); // the monospace readout
  },
  getValue: (el) => (el.dataset.value ? Number(el.dataset.value) : null),
  setValue(el, v) { el.dataset.value = v || ''; paint(el); },
});
// set() stores the level, calls paint() to light the bars up to it
// (calm green to hot red), and commits with api.onChange(level)
// then, in any form:
{ type: 'level', name: 'priority', label: 'Alert threshold', required: true }

api.onChange(value) commits the value through the same path a built-in edit takes. A validate(value, item, el) hook may return true to accept, false or a message to refuse, or a promise of either, and destroy(el) cleans up when the form is removed.

Several forms, one record, and a stepper for long ones

A record often spreads over several cells of a layout. Put a form in each and join them with createFormGroup: the group reads, writes, validates and locks as one form, and a rule in one form can watch a field in another. For a long form, give it a list of steps and it becomes a wizard: Next validates the current step first, Back never does, and the last step reads Finish.

const group = createFormGroup([header, details]);
group.validate();                // one answer for every form
group.getValue();                // both forms, merged

const wizard = createForm(el, {
  steps: [
    { id: 'account', label: 'Account', rows: [/* ... */] },
    { id: 'billing', label: 'Billing', rows: [/* ... */] },
  ],
});
await wizard.next();             // false while the current step is invalid

Try it below. Three steps, Company, Contact and Plan, each with required fields. Press Next with a step empty and it stays put and shows the messages. Fill the step and it moves on. Next and Back come with the stepper. Back never asks. The last step lists what you entered, and Finish validates it once more.

Building…
Loading a live form…
const form = createForm(el, {
  steps: [
    { id: 'company', label: 'Company', rows: [
      { type: 'input', name: 'company', label: 'Company', required: true },
      { type: 'number', name: 'staff', label: 'Employees', min: 1, required: true },
    ] },
    { id: 'contact', label: 'Contact', rows: [
      { type: 'input', name: 'person', label: 'Contact name', required: true },
      { type: 'input', subtype: 'email', name: 'email', label: 'Email',
        required: true, validation: 'email' },
    ] },
    { id: 'plan', label: 'Plan', rows: [
      { type: 'chips', name: 'plan', label: 'Plan', required: true,
        allowEmpty: true, options: ['Starter', 'Growth', 'Scale'] },
    ] },
  ],
});
await form.next();   // refuses while the step is invalid
await form.back();   // never validates
form.activeStep;     // 2 on the last step: show form.getValue() as the summary

Every framework, and the design system you already use

The form has wrappers for React, Vue, Svelte and Angular. On an htmx page, a server-rendered form is enhanced in place, keeping its field names and its posts. It follows the four design-system presets and dark mode, so a dark form on a light page keeps every label readable.

Try it

Development on localhost is free and needs no key, so you can lay out a real record and see it validate before you decide anything.

npm install @toclocoinc/lattice-grid

See every control and a live form on the form page, read the form reference, and see pricing for the licence, which is per domain.

Read next

All posts RSS