← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
import { useEffect, useRef, useState, type FormEvent } from 'react'
import { DoubleCheckButton } from './double-check-button.tsx'
import { IconTrash2 } from '../icons/trash-2.tsx'
import { IconPlus } from '../icons/plus.tsx'
import { createBusyGate } from './busy.ts'
export type NotesDemoNote = {
id: string
text: string
createdAt: string
}
export type NotesDemoProps = {
notes: Array<NotesDemoNote>
actionHref: string
}
type Row = NotesDemoNote & { optimistic?: boolean }
/**
* Notes list + add form as a progressive-enhancement island.
* Without JS: native POST forms + 303 redirect. With JS: optimistic add/delete.
*/
export function NotesDemo({ notes, actionHref }: NotesDemoProps) {
const [rows, setRows] = useState<Array<Row>>(() => notes.map((n) => ({ ...n })))
const [showBusy, setShowBusy] = useState(false)
const [adding, setAdding] = useState(false)
const deletePending = useRef(new Set<string>())
const addPending = useRef(new Set<string>())
const addBusy = useRef(createBusyGate({ delayMs: 200, minDurationMs: 400 }))
const propsKey = useRef(JSON.stringify(notes))
useEffect(() => {
const nextKey = JSON.stringify(notes)
if (nextKey === propsKey.current) return
if (deletePending.current.size > 0 || addPending.current.size > 0) return
propsKey.current = nextKey
setRows(notes.map((n) => ({ ...n })))
}, [notes])
function focusAddInput() {
const el = document.querySelector<HTMLInputElement>(
'[data-notes-demo] input[name="text"]',
)
el?.focus()
}
async function postAction(formData: FormData) {
const res = await fetch(actionHref, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { Accept: 'application/json' },
})
let data: unknown = null
try {
data = await res.json()
} catch {
data = null
}
return { ok: res.ok, data }
}
async function onAddSubmit(event: FormEvent<HTMLFormElement>) {
event.preventDefault()
const form = event.currentTarget
const formData = new FormData(form)
formData.set('intent', 'add')
const text = String(formData.get('text') || '').trim()
if (!text) return
const tempId = `optimistic-${crypto.randomUUID()}`
const createdAt = new Date().toISOString()
const wasIdle = addPending.current.size === 0
addPending.current.add(tempId)
setRows((prev) => [{ id: tempId, text, createdAt, optimistic: true }, ...prev])
form.reset()
if (wasIdle) {
setAdding(true)
addBusy.current.start((phase) => setShowBusy(phase === 'busy'))
}
focusAddInput()
try {
const { ok, data } = await postAction(formData)
const note =
data && typeof data === 'object' && data !== null && 'note' in data
? (data as { note?: NotesDemoNote }).note
: null
if (!ok || !note?.id) throw new Error('add failed')
setRows((prev) =>
prev.map((row) =>
row.id === tempId
? { id: note.id, text: note.text, createdAt: note.createdAt }
: row,
),
)
} catch {
setRows((prev) => prev.filter((row) => row.id !== tempId))
const input = form.querySelector<HTMLInputElement>('input[name="text"]')
if (input && !input.value) input.value = text
} finally {
addPending.current.delete(tempId)
if (addPending.current.size === 0) {
await addBusy.current.stop((phase) => setShowBusy(phase === 'busy'))
setAdding(false)
}
}
}
async function onDeleteSubmit(event: FormEvent<HTMLFormElement>, noteId: string) {
event.preventDefault()
if (deletePending.current.has(noteId)) return
const form = event.currentTarget
const formData = new FormData(form)
formData.set('intent', 'delete')
formData.set('id', noteId)
let removed: Row | undefined
let index = -1
setRows((prev) => {
index = prev.findIndex((row) => row.id === noteId)
if (index < 0) return prev
removed = prev[index]
deletePending.current.add(noteId)
return prev.filter((row) => row.id !== noteId)
})
if (!removed) return
try {
const { ok } = await postAction(formData)
if (!ok) throw new Error('delete failed')
} catch {
setRows((prev) => {
const copy = [...prev]
copy.splice(index, 0, removed!)
return copy
})
} finally {
deletePending.current.delete(noteId)
}
}
const addLabel = showBusy ? 'Adding…' : 'Add'
return (
<section className="pak-card pak-stack" data-notes-demo>
<form
method="post"
action={actionHref}
className="pak-row"
onSubmit={(event) => void onAddSubmit(event)}
>
<input type="hidden" name="intent" value="add" />
<label className="pak-grow" style={{ flex: '1 1 180px' }}>
<span className="visually-hidden">New note</span>
<input
className="pak-input"
name="text"
maxLength={120}
placeholder="Write a note…"
required
aria-label="New note"
/>
</label>
<button
className="pak-btn pak-btn-accent"
type="submit"
style={{ flex: '0 0 auto' }}
data-pending={adding ? 'true' : undefined}
aria-busy={adding ? 'true' : undefined}
>
<span className="pak-cluster pak-gap-2">
<IconPlus size={18} />
{addLabel}
</span>
</button>
</form>
<ul className="pak-list" id="notes" aria-live="polite">
{rows.length === 0 ? (
<li className="pak-list-item">
<span className="pak-muted">No notes yet</span>
</li>
) : (
rows.map((note) => {
const pendingAdd = Boolean(note.optimistic)
return (
<li
className="pak-list-item"
key={note.id}
data-note-id={note.id}
data-pending={pendingAdd ? 'true' : undefined}
aria-busy={pendingAdd ? 'true' : undefined}
>
<div>
<strong>{note.text}</strong>
{pendingAdd ? (
<span className="pak-pending-label"> Saving…</span>
) : null}
<div className="pak-muted" style={{ fontSize: '0.8rem' }}>
{note.createdAt}
</div>
</div>
{pendingAdd ? (
<span className="pak-muted" aria-hidden="true" />
) : (
<form
method="post"
action={actionHref}
onSubmit={(event) => void onDeleteSubmit(event, note.id)}
>
<input type="hidden" name="intent" value="delete" />
<input type="hidden" name="id" value={note.id} />
<DoubleCheckButton
label="Delete"
confirmLabel="Are you sure?"
icon={<IconTrash2 size={18} />}
/>
</form>
)}
</li>
)
})
)}
</ul>
</section>
)
}