Show HN: Declarative-forms – await an object the way prompt() awaits a string

WolfOliver1 pts0 comments

declarative-forms

Skip to content

Appearance

MenuReturn to top

declarative-forms ​<br>Ask the user for an object, the way prompt() asks for a string.<br>Every other form library gives you a component to mount. This one gives you a function to call:<br>tsconst release = await ask([<br>{ name: 'title', displayName: 'Release title' },<br>{ name: 'notes', kind: 'textarea', displayName: 'What changed' },<br>name: 'reviewers',<br>kind: 'select',<br>multiple: true,<br>displayName: 'Sign-off from',<br>options: () => fetchReviewers(),<br>},<br>]);

// { title: 'Sunrise 2.0', notes: '…', reviewers: ['Ada', 'Grace'] }<br>That is the whole integration. 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 the answer arrives where you asked. The dialog draws itself, loads its own options, keeps every field in sync, and resolves.<br>prompt() is the one form API the browser gives you for free, and the shape everybody finds obvious: you ask, the browser draws the dialog, you get the answer. Its only flaw is that it asks for exactly one string. declarative-forms keeps that shape and removes the limit — you describe the record you want, and you get a plain object back.<br>Asking for data → is the two-minute version of this idea, including the nine-line ask helper. Everything below follows from it.<br>Try it ​<br>Press the button: three tabs, an option list that reloads when the team changes, a credits list, and a Schedule… dialog that opens on top of the first, with a third on top of that. Values shows the object you would receive, updated as you type. Below the panel is the code that produces all of it — imports, descriptors and the call that opens the dialog, with nothing left out. The button runs exactly that code, and none of it describes rendering.<br>Opens a dialogOpen the release dialog

Values<br>tsimport 'declarative-forms/styles.css';<br>import { DeclarativeForm, html } from 'declarative-forms';

// Pretend this is your API. Any options function may be async.<br>const reviewersOf = async (team) => {<br>await new Promise((resolve) => setTimeout(resolve, 500));<br>return (<br>design: ['Ada Lovelace', 'Grace Hopper', 'Lin Chen'],<br>infra: ['Radia Perlman', 'Alan Turing'],<br>}[team] ?? []<br>);<br>};

// A button in the dialog below opens this one, so it appears on top of it.<br>// Its own list then opens a third dialog on top of that.<br>const openSchedule = (release) =><br>new DeclarativeForm({<br>fields: [<br>name: 'when',<br>kind: 'cards',<br>displayName: 'Publish',<br>defaultValue: 'now',<br>cards: [<br>{ value: 'now', content: html('Immediatelyon confirm') },<br>{ value: 'at', content: html('At a set timeyour timezone') },<br>],<br>},<br>name: 'at',<br>type: 'datetime-local',<br>displayName: 'Moment',<br>isActive: ({ data }) => data['when'] === 'at',<br>},<br>name: 'freezes',<br>kind: 'array',<br>displayName: 'Never publish during',<br>newButtonLabel: 'Add window',<br>of: [<br>{ name: 'reason', displayName: 'Reason', placeholder: 'Conference' },<br>{ name: 'until', type: 'date', displayName: 'Until' },<br>],<br>renderEntry: (entry) => `${entry['reason']} — until ${entry['until']}`,<br>isValidRecord: (entry) => Boolean(entry['reason'] && entry['until']),<br>suggested: [{ reason: 'Company all-hands', until: '2026-09-01' }],<br>},<br>name: 'recap',<br>kind: 'message',<br>// The values of every open dialog, outermost first.<br>message: ({ data, stackData }) =><br>html(`Publishing ${stackData[0]['title'] || 'this release'}<br>${data['when'] === 'now' ? 'as soon as you confirm' : 'later'}.`),<br>},<br>],<br>buttons: {<br>Apply: {<br>id: 'apply',<br>action: (values) => {<br>const at = values['when'] === 'now' ? 'Immediately' : values['at'];<br>release.field('publishAt').setValue(at);<br>void release.update();<br>},<br>},<br>},<br>onCancel: () => {},<br>}).openInModal();

const release = new DeclarativeForm({<br>fields: [<br>// `tab` groups fields. The tab bar builds itself, and hides a tab when<br>// none of its fields is active.<br>name: 'title',<br>displayName: 'Release title',<br>tab: 'Notes',<br>placeholder: 'Sunrise 2.0',<br>tooltip: 'Shown at the top of the changelog',<br>},<br>{ name: 'notes', kind: 'textarea', displayName: 'What changed', tab: 'Notes' },<br>// Derived, and never shown. Recalculated before any button action runs.<br>// Watch `slug` in the values panel above.<br>name: 'slug',<br>kind: 'computed',<br>compute: ({ data }) =><br>'/releases/' +<br>String(data['title'] || 'untitled')<br>.toLowerCase()<br>.replace(/[^a-z0-9]+/g, '-'),<br>},<br>name: 'publishAt',<br>displayName: 'Publish at',<br>tab: 'Notes',<br>defaultValue: 'Immediately',<br>},<br>name: 'visibility',<br>kind: 'select',<br>displayName: 'Visible to',<br>tab: 'Audience',<br>defaultValue: 'team',<br>options: [<br>{ value: 'team', label: 'One team' },<br>{ value: 'company', label: 'Everyone here' },<br>{ value: 'public', label: 'The public' },<br>],<br>},<br>name: 'team',<br>kind: 'select',<br>displayName: 'Which team',<br>tab: 'Audience',<br>defaultValue: 'design',<br>options: [<br>{ value: 'design', label: 'Design' },<br>{ value: 'infra', label: 'Infrastructure' },<br>],<br>// A hidden field disappears from getValues() completely.<br>isActive: ({ data }) =>...

name displayname release kind dialog data

Related Articles