Skip to content

Kody is live

Watch the launch video — what Kody is, and why it exists.

← 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>
			)
		}
	},
)