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.

app/ui/notes-demo.tsx

319 lines · 9.1 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'
import {
	connectPackageRealtime,
	type PackageRealtimeConnection,
	type RealtimeClientStatus,
} from '../../src/realtime-client.ts'
import { NOTES_CHANGED_TYPE } from '../../src/realtime.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.
 * Live sync: package realtime notifies `notes.changed`; packageStorage stays
 * source of truth and the island refetches the JSON list when idle.
 */
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 })
		let liveStatus: RealtimeClientStatus = 'idle'
		let connection: PackageRealtimeConnection | null = null
		let reloadInFlight = false

		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 reloadNotesFromServer() {
			if (typeof window === 'undefined') return
			if (deletePending.size > 0 || addPending.size > 0) return
			if (reloadInFlight) return
			reloadInFlight = true
			try {
				const res = await fetch(handle.props.actionHref, {
					method: 'GET',
					credentials: 'same-origin',
					cache: 'no-store',
					headers: { Accept: 'application/json' },
				})
				if (!res.ok) return
				const data = (await res.json()) as { notes?: Array<NotesDemoNote> }
				if (!Array.isArray(data?.notes)) return
				if (deletePending.size > 0 || addPending.size > 0) return
				rows = data.notes.map((n) => ({ ...n }))
				propsKey = JSON.stringify(data.notes)
				handle.update()
			} catch {
				// Keep optimistic / current rows on transient fetch errors.
			} finally {
				reloadInFlight = false
			}
		}

		function ensureRealtime() {
			if (typeof window === 'undefined' || connection) return
			const appBase =
				document.documentElement.getAttribute('data-app-base') || ''
			try {
				connection = connectPackageRealtime({
					appBasePath: appBase,
					onEvent(data) {
						if (
							data &&
							typeof data === 'object' &&
							(data as { type?: unknown }).type === NOTES_CHANGED_TYPE
						) {
							void reloadNotesFromServer()
						}
					},
					onStatus(status) {
						liveStatus = status
						handle.update()
					},
				})
			} catch {
				liveStatus = 'closed'
			}
		}

		if (typeof window !== 'undefined') {
			ensureRealtime()
		}

		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()
			}
		}

		function liveLabel() {
			if (liveStatus === 'open') return 'Live'
			if (liveStatus === 'connecting' || liveStatus === 'reconnecting') {
				return '…'
			}
			return '\u00a0'
		}

		return () => {
			syncFromPropsWhenIdle()
			const adding = addBusy.isActive
			const addLabel = addBusy.showBusy ? 'Adding…' : 'Add'

			return (
				<section class="pak-card pak-stack" data-notes-demo>
					<div
						class="pak-row"
						style="justify-content:flex-end;min-height:1.1rem;margin:0"
						aria-live="polite"
					>
						<span
							class="pak-muted"
							style="font-size:0.75rem;line-height:1.1rem"
							data-notes-live={liveStatus}
						>
							{liveLabel()}
						</span>
					</div>
					<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>
			)
		}
	},
)