v1.6.0

<bmx-form>

A form drawn from JSON: fields, sections, repeating sections and pages, with conditions, worked-out values and checks written in a small, safe expression language - and the library's own fields to draw them, so every input, select, date picker and upload behaves as it does anywhere else.

18 properties · 7 events · 17 methods · 19 parts

Example

A form in three steps, drawn from JSON. The address shows only for delivery, each line works out its amount, the totals follow, and postage is free from £50. Leave a field to see its check; send the form to see the summary of problems. Answers are kept as a draft in this browser.
Show markup
<bmx-form id="ex-form" autosave="example-order" locale="en-GB" style="--bmx-form-max-width: 46rem"></bmx-form>

<div class="row" style="margin-block-start: 1rem">
  <span class="note" id="ex-form-out">
    A form in three steps, drawn from JSON. The address shows only for delivery, each line works out its amount, the
    totals follow, and postage is free from £50. Leave a field to see its check; send the form to see the summary of
    problems. Answers are kept as a draft in this browser.
  </span>
</div>

<script type="module">
  await customElements.whenDefined('bmx-form');
  const form = document.getElementById('ex-form');
  const out = document.getElementById('ex-form-out');

  form.schema = {
    title: 'Order stationery',
    description: 'Tell us who you are, what you need, and where it should go.',
    review: { title: 'Check your order', description: 'Change anything that is not right, then send it.' },
    submitLabel: 'Send order',
    successMessage: 'Thank you, {{ name }}. Your order for {{ TEXT(total, 2) }} pounds is on its way.',
    pages: [
      {
        title: 'Your details',
        fields: [
          { type: 'text', name: 'name', label: 'Full name', required: true, autocomplete: 'name', width: 'half' },
          { type: 'email', name: 'email', label: 'Email', required: true, width: 'half' },
          {
            type: 'radio', name: 'contact', label: 'How should we contact you?', orientation: 'horizontal',
            options: [{ value: 'email', label: 'Email' }, { value: 'phone', label: 'Phone' }], defaultValue: 'email',
          },
          { type: 'tel', name: 'phone', label: 'Phone number', requiredIf: "contact = 'phone'", visibleIf: "contact = 'phone'", width: 'half' },
          { type: 'select', name: 'team', label: 'Team', width: 'half', options: ['Finance', 'Operations', 'Sales', 'Support'] },
        ],
      },
      {
        title: 'Your order',
        fields: [
          {
            type: 'repeat', name: 'lines', label: 'Items', itemLabel: 'Item {{ $row }}', layout: 'table', minItems: 1, maxItems: 8, addLabel: 'Add another item',
            fields: [
              {
                type: 'select', name: 'item', label: 'Item', required: true, width: 'half',
                options: [
                  { value: 'pens', label: 'Pens, box of 10 (£4.50)' },
                  { value: 'pads', label: 'A4 pads, pack of 5 (£7.20)' },
                  { value: 'folders', label: 'Folders, pack of 20 (£9.90)' },
                  { value: 'stapler', label: 'Stapler (£12.00)' },
                ],
              },
              { type: 'number', name: 'qty', label: 'Quantity', min: 1, max: 50, integer: true, defaultValue: 1, required: true, width: 'quarter' },
              {
                type: 'calculated', name: 'amount', label: 'Amount', format: 'currency', currency: 'GBP', width: 'quarter',
                value: "qty * IFS(item = 'pens', 4.5, item = 'pads', 7.2, item = 'folders', 9.9, item = 'stapler', 12, true, 0)",
              },
            ],
          },
          { type: 'radio', name: 'delivery', label: 'Delivery', orientation: 'horizontal', defaultValue: 'collect',
            options: [{ value: 'collect', label: 'Collect from reception' }, { value: 'post', label: 'Post it to me' }] },
          {
            type: 'section', name: 'address', label: 'Delivery address', visibleIf: "delivery = 'post'",
            fields: [
              { type: 'text', name: 'line1', label: 'Address', required: true, autocomplete: 'address-line1' },
              { type: 'text', name: 'town', label: 'Town or city', required: true, width: 'half', autocomplete: 'address-level2' },
              { type: 'text', name: 'postcode', label: 'Postcode', required: true, width: 'half', autocomplete: 'postal-code',
                pattern: '[A-Za-z]{1,2}\\d[A-Za-z\\d]? ?\\d[A-Za-z]{2}', messages: { pattern: 'Enter a postcode, like SW1A 1AA.' } },
              { type: 'date', name: 'date', label: 'Deliver on or after', min: '=ADDDAYS(TODAY(), 2)', width: 'half',
                description: 'We need two days to pack an order.' },
            ],
          },
          { type: 'calculated', name: 'subtotal', label: 'Items', value: 'SUM(lines.amount)', format: 'currency', width: 'third' },
          { type: 'calculated', name: 'postage', label: 'Postage', value: "IF(delivery = 'post' and subtotal < 50, 3.95, 0)", format: 'currency', width: 'third',
            description: 'Free from £50.' },
          { type: 'calculated', name: 'total', label: 'Total', value: 'subtotal + postage', format: 'currency', width: 'third' },
        ],
      },
      {
        title: 'Finish',
        fields: [
          { type: 'rating', name: 'rating', label: 'How easy was this form?', scaleMax: 5 },
          { type: 'textarea', name: 'notes', label: 'Anything else?', maxLength: 300, visibleIf: 'rating > 0 and rating < 4',
            description: 'Sorry it was not easier - tell us what would help.' },
          { type: 'signature', name: 'signature', label: 'Signed for by', required: true },
          { type: 'checkbox', name: 'agree', label: 'I will collect or receive this order for {{ COALESCE(team, "my team") }}', required: true },
        ],
      },
    ],
  };

  form.addEventListener('bmxFormSubmit', event => {
    out.textContent = `bmxFormSubmit - ${JSON.stringify(event.detail.data).slice(0, 220)}…`;
  });
  form.addEventListener('bmxFormInvalid', event => {
    out.textContent = `bmxFormInvalid - ${event.detail.errors.length} problem(s).`;
  });
</script>

THE SCHEMA

schema is { title, fields: [...] }, or { title, pages: [{ title, fields }] } for a form in steps. Each field has a type (text, email, number, select, radio, checkboxes, checkbox, date, file, signature, calculated, section, repeat and more), a name to file its answer under, a label, and whatever its type takes. width lays fields side by side (half, third); a narrow form stacks them.

LOGIC

visibleIf, enabledIf, requiredIf and readonlyIf are expressions: contact = 'phone', age >= 18 and country in ['UK', 'IE']. value makes a field hold a result (qty * price, SUM(lines.amount)), and options can have a visibleIf of their own, or come from optionsUrl, read again when the answers it names change. Labels and content take {{ placeholders }}. Hidden answers count as blank and are not sent.

CHECKS

required, lengths, ranges (numbers or dates, fixed or =TODAY()), patterns, email and web addresses, list sizes, file types and sizes, and rules of your own ({ expression: "end >= start", message }). Messages appear as each field is left, and all at once - with a summary that links to each - when the form is sent or a page is left. validators add the page's own checks, which may ask a server. The engine is a plain module, so a server can check a submission against the same schema.

SENDING

bmxFormSubmit carries the answers; cancel it to send them yourself. With action, the form posts them as JSON (or as multipart form data when there are files) and shows the server's field errors - { errors: { path: message } } - against the fields. With name, the answers also go into an enclosing <form> as JSON. autosave keeps a draft in the browser and fills it back in.

Properties

PropertyAttributeTypeDefaultDescription
action action string — A URL to send the answers to. Without one, the page handles bmxFormSubmit.
autosave autosave string — A name to keep a draft of the answers under in this browser, filled back in next time.
disabled disabled boolean false Turns every field off.
functions property only Record<string, BmxFormFunction> — Functions for expressions, by name: { VAT: (n) => n * 0.2 }. A property.
hideActions hide-actions boolean false Leaves out the buttons, for a page that drives the form from script.
label label string — The form's accessible name, when it has no title.
locale locale string — The language numbers and dates are shown in. The reader's, by default.
method method string 'POST' The method action is sent with.
name name string — With a name, the answers go into an enclosing <form> as JSON under it.
page page number 0 The page on show, from 0.
readonly readonly boolean false Shows the answers as text rather than as fields.
requestInit request-init RequestInit | string — Options for the request to action and to optionsUrl (headers, credentials), as a property or JSON.
schema schema BmxFormSchema | string — The form: { title, fields } or { title, pages }, as a property or JSON.
src src string — A URL to read the schema from as JSON.
strings strings Partial<BmxFormStrings> | string — Replacements for the form's own wording, as a property or JSON.
validateOn validate-on 'blur' | 'input' | 'submit' 'blur' When a field shows its problem: once it is left (blur), as it is typed in (input), or only when the form is sent (submit).
validators property only Record<string, BmxFormValidator> — The page's own checks, by field (email, or lines.qty for a column of rows). A property.
value value Record<string, unknown> | string {} The answers, as a property or JSON. Changes write back here.

Events

EventDetailDescription
bmxFormChange BmxFormChangeDetail An answer changed.
bmxFormInvalid BmxFormInvalidDetail The form was sent, or a page left, with problems.
bmxFormPageChange BmxFormPageChangeDetail Another page is on show.
bmxFormReset void The form was emptied back to its defaults.
bmxFormSubmit BmxFormSubmitDetail The form is valid and about to be sent. Cancel it to send the answers yourself.
bmxFormSubmitError BmxFormSubmitErrorDetail action refused the answers, or could not be reached.
bmxFormSubmitted BmxFormSubmittedDetail action accepted the answers.

Methods

MethodSignatureDescription
clearErrors clearErrors() => Promise<void> Clears the messages set by setErrors.
focusField focusField(path: string) => Promise<void> Moves focus to a field, by path, going to its page first.
getData getData(all?: boolean) => Promise<Record<string, unknown>> The answers: by default as they would be sent (hidden ones left out); with all, every answer kept.
getSummary getSummary() => Promise<BmxFormSummaryItem[]> The answers as a reader checks them: each field on show with its answer as text.
getValue getValue(path: string) => Promise<unknown> One answer, by path (email, lines[0].qty).
goToPage goToPage(page: number) => Promise<void> Goes to a page, from 0, without checking the one on show.
load load(url: string) => Promise<void> Reads the schema from a URL.
next next() => Promise<boolean> Checks the page on show and goes to the next one; false if the page has problems.
previous previous() => Promise<void> Goes back a page.
reportValidity reportValidity() => Promise<boolean> Checks the whole form and shows its problems; true when there are none.
reset reset() => Promise<void> Empties the form back to its defaults, on its first page.
setData setData(data: Record<string, unknown>, merge?: boolean) => Promise<void> Replaces the answers, or with merge adds to them.
setErrors setErrors(errors: Record<string, string>) => Promise<void> Shows messages against fields, by path - a server's answer, typically. They clear as each field is changed.
setValue setValue(path: string, value: unknown) => Promise<void> Sets one answer, by path, as though it had been typed.
submit submit() => Promise<boolean> Checks the form and, when it passes, sends it: bmxFormSubmit, then action if there is one.
toFormData toFormData() => Promise<FormData> The answers to send, as FormData (lines[0][qty]), files included.
validate validate(page?: number) => Promise<BmxFormError[]> The problems, without showing them: the whole form's, or one page's. Waits for the page's own validators.

CSS shadow parts

PartDescription
actions the buttons under the form.
content text from the schema.
error a message under a field the form draws itself.
error-summary the list of problems shown when the form is sent.
field the cell around one field.
form the form itself.
header the title and description.
notice the "restored" notice and the sending error.
output a worked-out value.
page the fields of the page on show.
progress the steps of a form in pages.
repeat a repeating section.
review the answers on the "check your answers" step.
row one row of a repeating section.
row-label a row's label.
section a section.
signature a signature box.
step one step. Also step-current, step-done.
success what shows once the form is sent.

CSS custom properties

PropertyDescription
--bmx-form-accent The progress steps and links.
--bmx-form-border Lines around sections, rows and the summary.
--bmx-form-error-color Messages the form draws itself, and the summary's edge.
--bmx-form-gap The space between fields.
--bmx-form-max-width The widest the form grows.
--bmx-form-output-background Behind a worked-out value.
--bmx-form-radius Corners of sections, rows and boxes.
--bmx-form-section-background Behind a section and a repeating row.