Skip to content

Repository files navigation

declarative-forms

Ask the user for an object, the way prompt() asks for a string.

Every other form library gives you a component to mount. This one gives you a function to call. There is no component, no form state, no mount point, and no place in your tree where the form has to live. You ask a question from wherever you happen to be standing in your code, and you get the answer back.

import { ask } from 'declarative-forms';

const release = await ask([
  { name: 'title', displayName: 'Release title' },
  { name: 'notes', kind: 'textarea', displayName: 'What changed' },
  {
    name: 'reviewers',
    kind: 'select',
    multiple: true,
    displayName: 'Sign-off from',
    options: () => fetchReviewers(),
  },
]);

// { title: 'Sunrise 2.0', notes: '…', reviewers: ['Ada', 'Grace'] }

That is the whole integration. No <Form>, no useForm, no onChange, no useState, no layout, no <div>. The dialog draws itself, loads its own options, keeps every field in sync, and resolves.

ask ships with the library — what it takes and what it resolves with.

📖 Read the documentation · 🎛 Try the live demo

Why a call and not a component

window.prompt() is the one form API the browser gives you for free. You ask, the browser draws the dialog, you get the answer. No markup, no state, no layout, no lifecycle. Its only flaw is that it can ask for exactly one string.

Everything the industry built to replace it went the other way — into the component tree:

// The component model: the form is a thing that lives somewhere.
const { register, handleSubmit } = useForm();
return (
  <Modal open={open} onClose={() => setOpen(false)}>
    <form onSubmit={handleSubmit(onSubmit)}>
      <input {...register('title')} /></form>
  </Modal>
);

Now the form has a location. It needs a parent, a piece of open state, a close handler, a submit handler, and a route from the answer back to the code that wanted it. For a product's signup page, that is fine — you were going to lay that page out anyway. For the two hundredth settings dialog in an internal tool, it is all overhead: you did not want a form, you wanted an answer.

declarative-forms keeps prompt()'s shape and removes its one-string limit. The form is not a thing that lives somewhere. It is a question, asked and answered:

if (await confirmDetails()) {  }

const filters = await ask(filterFields);
const entry   = await ask(entryFields, { confirmLabel: 'Add' });

What that model gives you that a component cannot

Dialogs stack, and the inner one can read the outer ones. Because a form is a call and not a node, opening one from inside another is just... calling it. The library keeps the stack, and any field in any dialog can read the values of every dialog beneath it through stackData — with no lifted state, no context provider, and no prop drilling, because there is no tree to lift through.

{
  name: 'recap',
  kind: 'message',
  // stackData[0] is the outermost dialog, still open behind this one.
  message: ({ data, stackData }) =>
    `Publishing ${stackData[0]['title']} ${data['when'] === 'now' ? 'now' : 'later'}.`,
}

Lists of records are just the same call again. An array field opens one dialog per entry, which is the natural shape for a record, and awkward to do inline.

Editing is the same call with the object in it. prompt() takes a starting value; so does this. One field list serves both directions — extra keys on the object you pass are ignored, so the record you already have goes in as-is.

const updated = await ask(userFields, { defaultValues: user });

Works anywhere, because it needs nothing. Plain DOM plus one web component, no runtime dependencies. Call it from React, Vue, Svelte, Angular, an Electron main-window script, or a <script type="module"> tag in a static page. It never touches your render tree, so there is nothing to integrate.

The descriptor is live, not static. options, isActive, defaultValue, placeholder, message, tab and compute may each be a function of the current values, and reloadOnChangeOf declares which field depends on which. Options loaded from your API that change when another field changes are the core feature, not an extension point.

Install

npm install declarative-forms
import 'declarative-forms/styles.css';

That is the default look, and it follows the operating system's light/dark setting on its own. The v1 look ships as declarative-forms/classic.css.

No build step is required — the ESM build loads directly in a <script type="module">.

The full API, if you want the object instead of the promise

ask is a thin wrapper over the object underneath — a normal object you can hold on to, embed in a page instead of a dialog, subscribe to, and drive from code:

import { DeclarativeForm } from 'declarative-forms';

const form = new DeclarativeForm({
  fields: [...],
  onConfirm: (values) => save(values),
  onCancel: () => {},
});

form.openInModal();                       // …or:
form.appendInElement(document.querySelector('#panel'));

form.subscribeOnInput((values) => renderPreview(values));
form.field('role')?.setValue('Admin');

See the API reference.

The ten field kinds

text · textarea · select (single, multiple, async, searchable) · checkbox · message · file · computed · cards · custom · array

Full descriptions in Field kinds.

Where it fits

Best in settings and metadata dialogs for tool-like apps: many optional fields, grouped into tabs, where the available choices depend on what the user already picked.

  • admin panels and settings dialogs
  • configuration flows with conditions and dependencies
  • modal wizards with several steps
  • forms whose options come from a server and depend on other fields
  • editing lists whose entries are records of their own
  • internal tools that need a data-driven UI without a framework dependency

Where it does not fit

Plainly, so you can rule it out fast:

  • It is not a general-purpose form library. It renders one fixed layout. If you need control over the markup — a product signup page, a marketing form — use a state library and write the markup yourself.
  • There is no validation framework. No required, no rule objects, no schema. There are per-field error, warning and loading messages (setTooltipError and friends), and isValidRecord and a button's isActive gate submission — but you write each check yourself, and the message and the gate are separate things you wire up separately. See Validation.
  • Accessibility is unfinished. Labels, ids, focusable buttons and keyboard-reachable checkboxes are correct. Dialog semantics, a focus trap, and combobox ARIA are not. Read Accessibility in full before using it where conformance is required.
  • The rendered DOM is a frozen contract. Class names, ids and structure are stable across the v1 → v2 rewrite, so existing stylesheets keep working. See the DOM contract.

Documentation

License

MIT © Oliver Wolf

About

Ask the user for an object, the way prompt() asks for a string.

Resources

Code of conduct

Contributing

Stars

3 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages