Skip to content
← Public packages

@kentcdodds/package-app-kit

Design tokens, PWA install/update, About/version, cache helpers, and optional realtime notes sync for Kody package apps.

FollowFork

starter-tanstack/app/ui/notes-demo.tsx

229 lines · 6.4 KB · TypeScript
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>
	)
}