← Public packages
@kentcdodds/package-app-kit
Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.
starter-remix/app/ui/notes-demo.tsx
231 lines · 6.7 KB · TypeScript/** @jsxRuntime automatic */
/** @jsxImportSource remix/ui */
import { clientEntry, on, type Handle } from 'remix/ui'
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 (clears
* the input, keeps focus) and isolated parallel optimistic adds/deletes — the
* form stays enabled so notes can be hammered in rapid succession.
*/
export const NotesDemo = clientEntry(
'kody:app#NotesDemo',
function NotesDemo(handle: Handle<NotesDemoProps>) {
let rows: Array<Row> = handle.props.notes.map((n) => ({ ...n }))
let propsKey = JSON.stringify(handle.props.notes)
const deletePending = new Set<string>()
const addPending = new Set<string>()
const addBusy = createBusyGate({ delayMs: 200, minDurationMs: 400 })
function syncFromPropsWhenIdle() {
const next = JSON.stringify(handle.props.notes)
if (next === propsKey) return
if (deletePending.size > 0 || addPending.size > 0) return
propsKey = next
rows = handle.props.notes.map((n) => ({ ...n }))
}
function focusAddInput() {
const el = document.querySelector<HTMLInputElement>(
'[data-notes-demo] input[name="text"]',
)
el?.focus()
}
async function postAction(formData: FormData) {
const res = await fetch(handle.props.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: Event) {
event.preventDefault()
const form = event.currentTarget as HTMLFormElement
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.size === 0
addPending.add(tempId)
rows = [{ id: tempId, text, createdAt, optimistic: true }, ...rows]
form.reset()
if (wasIdle) addBusy.start(() => handle.update())
handle.update()
// Keep caret in the field so Enter can fire another add immediately.
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')
rows = rows.map((row) =>
row.id === tempId
? { id: note.id, text: note.text, createdAt: note.createdAt }
: row,
)
} catch {
rows = rows.filter((row) => row.id !== tempId)
const input = form.querySelector<HTMLInputElement>('input[name="text"]')
if (input && !input.value) input.value = text
} finally {
addPending.delete(tempId)
if (addPending.size === 0) {
await addBusy.stop(() => handle.update())
}
handle.update()
}
}
async function onDeleteSubmit(event: Event, noteId: string) {
event.preventDefault()
if (deletePending.has(noteId)) return
const form = event.currentTarget as HTMLFormElement
const formData = new FormData(form)
formData.set('intent', 'delete')
formData.set('id', noteId)
const index = rows.findIndex((row) => row.id === noteId)
if (index < 0) return
const removed = rows[index]
deletePending.add(noteId)
// Optimistic remove — each row has its own inflight flag so deletes stay parallel.
rows = rows.filter((row) => row.id !== noteId)
handle.update()
try {
const { ok } = await postAction(formData)
if (!ok) throw new Error('delete failed')
} catch {
rows = [...rows.slice(0, index), removed, ...rows.slice(index)]
} finally {
deletePending.delete(noteId)
handle.update()
}
}
return () => {
syncFromPropsWhenIdle()
const adding = addBusy.isActive
const addLabel = addBusy.showBusy ? 'Adding…' : 'Add'
return (
<section class="pak-card pak-stack" data-notes-demo>
<form
method="post"
action={handle.props.actionHref}
class="pak-row"
mix={on('submit', (event) => void onAddSubmit(event))}
>
<input type="hidden" name="intent" value="add" />
<label class="pak-grow" style="flex:1 1 180px">
<span class="visually-hidden">New note</span>
<input
class="pak-input"
name="text"
maxlength="120"
placeholder="Write a note…"
required
aria-label="New note"
/>
</label>
<button
class="pak-btn pak-btn-accent"
type="submit"
style="flex:0 0 auto"
data-pending={adding ? 'true' : undefined}
aria-busy={adding ? 'true' : undefined}
>
<span class="pak-cluster pak-gap-2">
<IconPlus size={18} />
{addLabel}
</span>
</button>
</form>
<ul class="pak-list" id="notes" aria-live="polite">
{rows.length === 0 ? (
<li class="pak-list-item">
<span class="pak-muted">No notes yet</span>
</li>
) : (
rows.map((note) => {
const pendingAdd = Boolean(note.optimistic)
return (
<li
class="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 class="pak-pending-label"> Saving…</span>
) : null}
<div class="pak-muted" style="font-size:0.8rem">
{note.createdAt}
</div>
</div>
{pendingAdd ? (
<span class="pak-muted" aria-hidden="true" />
) : (
<form
method="post"
action={handle.props.actionHref}
mix={on('submit', (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>
)
}
},
)