Skip to content

4. The Form

A form for creating a new note. Submitting the form dispatches a note.create command. Validation is done by the browser. The form has no idea what handles the command.

In the sidebar template:

function renderNewNoteButton(): string {
return `
<kit-button
meta-command="dialog.open"
meta-prop-target="new-note-dialog"
>
New note
</kit-button>
`
}
export function renderNoteList(notes: Note[]): string {
return `
<kit-boundary surface="note-list" feature="notes">
${renderNewNoteButton()}
<ul class="note-list">${notes.map(renderNoteItem).join('')}</ul>
</kit-boundary>
`
}

The button declares meta-command="dialog.open" with target "new-note-dialog". The built-in dialogModule() (already installed in chapter 1) handles it.

At the bottom of the layout, just inside <kit-shell>:

<kit-dialog id="new-note-dialog" close-event="new-note.dismissed">
<form id="new-note-form">
<h2>New note</h2>
<kit-field label="Title" required>
<input name="title" autocomplete="off" />
</kit-field>
<kit-field label="Body">
<textarea name="body" rows="6"></textarea>
</kit-field>
<menu>
<kit-button
type="button"
meta-command="dialog.close"
meta-prop-target="new-note-dialog"
>
Cancel
</kit-button>
<kit-button type="submit">Save note</kit-button>
</menu>
</form>
</kit-dialog>

Three things to notice:

  • <kit-field label="Title" required> provides the visible label, asterisk, and required propagation. The <input> inside is real and natively validated.
  • The Cancel button has meta-command="dialog.close" — declarative close, no JS handler needed.
  • The Save button is a native submit button. The form’s submit event handles dispatch.
const form = document.getElementById('new-note-form') as HTMLFormElement
form.addEventListener('submit', async (e) => {
e.preventDefault()
const data = Object.fromEntries(new FormData(form).entries()) as Record<string, string>
const result = await shell.runtime.command({
type: 'note.create',
payload: {
title: data.title.trim(),
body: data.body.trim(),
},
})
if (result.ok) {
form.reset()
await shell.runtime.command({
type: 'dialog.close',
payload: { target: 'new-note-dialog' },
})
}
})

The form does three things:

  1. Prevents default submission.
  2. Reads the form data with FormData — a built-in.
  3. Dispatches a note.create command and awaits the result.

If anything fails, the form stays open. If it succeeds, we reset the form and close the dialog via another command — even closing the dialog is dispatched, not directly called.

Right now no module handles note.create. The console will show:

[kit:diagnostic] command.unhandled { commandType: 'note.create' }

Add a temporary in-memory handler in main.ts:

let notes = [...seedNotes]
shell.runtime.handleCommand('note.create', (cmd) => {
const note = {
id: `n_${crypto.randomUUID().slice(0, 8)}`,
title: String(cmd.payload?.title ?? ''),
body: String(cmd.payload?.body ?? ''),
updatedAt: Date.now(),
}
notes = [note, ...notes]
document.getElementById('main')!.innerHTML = renderApp(notes)
return { id: note.id }
})

Now: open the dialog, fill the form, click Save. The dialog closes, the new note appears at the top of the list. Refresh — the note is gone (we haven’t built persistence yet).

A working create flow that:

  • Uses native form validation (try submitting an empty title — the browser stops you).
  • Dispatches a command instead of calling a save function directly.
  • Closes the dialog via a command, not a direct call.

The form, the dialog, and the buttons don’t import a save function. They speak through commands.

We hand-rolled note.create in main.ts for now. In the next chapter we’ll move it into a real module — and the module will also persist notes to localStorage, so refreshing keeps them.

The persistence module. The first non-trivial module you’ll author from scratch. It exposes a provider, handles three commands, and emits two events for downstream observers.

Next: Chapter 5 — Persistence Module →