Editor
CRUD editor backed by database data when configured.
Live demo
01Implementation
02<script>
import Editor from '$lib/components/Editor.svelte';
let editorOpen = $state(false);
let editingItem = $state(null);
</script>
β
{#if editorOpen}
<Editor
mode={editingItem ? 'edit' : 'create'}
initialData={editingItem || {}}
usingDatabase={data.usingDatabase}
onSave={handleSave}
onCancel={() => editorOpen = false}
/>
{/if}Editor is a modal CRUD form wired to a small REST surface (GET/POST/PUT/DELETE under /editor/api). The demo page consumes server-loaded data and either persists changes through the API (when DATABASE_URL is set) or mutates local state in-memory. Validation, focus management, and error handling all live inside the Editor component itself.
Logic explainer
03What Does It Do? (Plain English)
Editor is a CRUD modal β a focused, accessible form panel for creating new records or editing existing ones, wired through to a REST API and ultimately a Postgres table. It is the "admin interface" for ExpandingCard data, but the pattern generalises to any record with a heading, two text bodies, an image, and some metadata.
Think of it as a specialised data-entry slip. You hand it a blank slip (mode='create') or a slip already filled in by somebody else (mode='edit' plus initialData). The user types, the form validates as they go, only nags about missing fields after they have touched them, and on submit it hands the data back to the parent through onSave. The parent is the one who decides whether to POST or PUT to /editor/api, which talks to editorData.ts, which talks to Neon β or, when DATABASE_URL is unset, falls back to in-memory constants so the demo still works.
This is not just a UI component. It is a small full-stack reference implementation: a modal, a REST surface, a server utility, a soft-delete schema, and a fallback path, all hanging together.
How It Works (Pseudo-Code)
CLIENT (Editor.svelte) βββββββββββββββββββββββββββββββββββββββββ
state:
formData = { ...initialData with sensible defaults }
errors = {} // field β message
touched = {} // field β was-blurred-or-typed-into
saving = false
derive isValid = errors has no keys
derive visibleErrors = errors filtered to keys with touched[key] === true
on mount:
snapshot previouslyFocused = document.activeElement
body.style.overflow = 'hidden' // lock page scroll
rAF β focus first form field
on input/blur:
touched[field] = true
re-run validateForm() via $effect (watches JSON.stringify(formData))
on Submit clicked:
mark all fields touched
if !isValid: focus first invalid field; return
saving = true
onSave(formData) // hand off to parent
// parent does the network call; component just closes
on Cancel / Escape / backdrop click:
onCancel() // parent flips its `open` to false
on cleanup:
body.style.overflow = previous
previouslyFocused?.focus()
PARENT (typically a demo page) ββββββββββββββββββββββββββββββββββ
on Save:
if mode === 'create': POST /editor/api with formData
else: PUT /editor/api with { id, ...formData }
on success: refresh list, close modal
API (/editor/api/+server.ts) ββββββββββββββββββββββββββββββββββββ
GET ?category=β¦ β loadEditorDataFromDatabase(category)
POST body β validate required fields β createEditorData(body) β 201
PUT body+id β updateEditorData(id, body) β 200 / 404
DELETE ?id=β¦ β deleteEditorData(id) β 200 / 404
errors: 400 (bad request), 404 (not found), 503 (no DB), 500 (everything else)
SERVER UTILITY (editorData.ts) ββββββββββββββββββββββββββββββββββ
load: if !DATABASE_URL β return FALLBACK_EDITOR_DATA
else SELECT β¦ WHERE is_active = TRUE ORDER BY display_order
map snake_case β camelCase
create: throw if no DB; SELECT MAX(display_order)+1; INSERT RETURNING *
update: throw if no DB; UPDATE β¦ COALESCE(${field}, field) WHERE id = ?
delete: throw if no DB; UPDATE SET is_active = FALSE (soft delete)
any error β log + return null/false/fallback (never crash the page)The Core Concept: The Touched/Validation Pattern
The "touched" pattern is the hinge that makes this form feel polite rather than aggressive. Three reactive primitives co-operate:
errorsβ derived from the currentformData. Re-runs on every keystroke via a$effectthat depends onJSON.stringify(formData). This is the truth about what is wrong with the form.touchedβ a record of which fields the user has actually engaged with. A field becomes touched on first input or blur, and stays touched.visibleErrorsβ$derived(Object.entries(errors).filter(([k]) => touched[k])). This is what the UI actually displays.
The result: open the modal in create mode and you see no red. Type into the heading and tab away β if it's empty, now it goes red. Submit prematurely and we mark every field touched in one go, exposing every problem at once and focusing the first invalid input. Browser-style validation, but warmer.
Validation rules:
| Field | Rules |
|---|---|
heading |
Required, β€255 chars |
compactText |
Required |
expandedText |
Required |
imageSrc |
Required, valid URL |
imageAlt |
Required, β€255 chars |
bgColor |
Optional, restricted to preset palette |
category |
Optional |
isValid = $derived(Object.keys(errors).length === 0) is what gates the Submit button β it disables until the form is truly clean, regardless of what is currently visible.
Server-Side Data Flow
The Editor itself is dumb about transport. Everything network-facing lives in three layers underneath it.
Layer 1 β REST API at /editor/api/+server.ts. Standard SvelteKit RequestHandler exports for GET, POST, PUT, and DELETE. Each:
- Parses input (JSON body for POST/PUT; query string for GET/DELETE).
- Performs minimum-viable validation (required fields, ID present and numeric).
- Delegates to the server utility.
- Returns appropriate HTTP status: 200 (read/update OK), 201 (create OK), 400 (bad input), 404 (not found), 503 (
DATABASE_URLnot configured), 500 (anything else). - Catches the specific "DATABASE_URL not configured" error from the utility and translates it into a 503 with a friendly message, so clients can distinguish "service down" from "your input is wrong".
Layer 2 β server utility at src/lib/server/editorData.ts. Four exported functions: loadEditorDataFromDatabase, createEditorData, updateEditorData, deleteEditorData. Each follows the same skeleton: read DATABASE_URL, branch to fallback if missing (read only) or throw if missing (writes), wrap the SQL in try/catch, transform snake_case columns to camelCase props on the way out. The update uses COALESCE(${field}, field) so callers can pass partial objects and the database keeps existing values for anything omitted.
Layer 3 β schema at database/schema_editor.sql. A single table, editor_data, with id, the user-facing columns, display_order, is_active, and created_at/updated_at. A trigger (update_editor_data_updated_at) keeps updated_at honest on every UPDATE. New items get MAX(display_order) + 1 for their category β appending without manual ordering.
The contract between layers is the EditorData type (src/lib/types.ts) on the client side and EditorDataRow on the database side. The utility owns the transformation; nothing else needs to think in snake_case.
Fallback Behaviour
The whole stack is built around the assumption that the database may not exist. This isn't an oversight β it's the demo principle. A new contributor clones the repo, runs bun run dev, and the Editor works immediately. Saves don't persist, but the form, the validation, the modal, the REST round-trip β all functional.
How the fallback decides:
- Reads (
loadEditorDataFromDatabase): ifDATABASE_URLis unset or matches a placeholder string, log a warning and returnFALLBACK_EDITOR_DATAfromsrc/lib/constants.ts. On any DB error, return the fallback rather than throw. Pages always render. - Writes (
create/update/delete): without a DB, throw a specific error. The API handler catches it and returns 503. The client surfaces this as a "Database not configured" message rather than silently pretending the write worked. - Component awareness: the Editor takes a
usingDatabaseboolean prop. When false, it shows a yellow banner inside the modal: "Changes won't be saved β no database connected." Set by the page's+page.server.tsfrom!!process.env.DATABASE_URL.
The dataSource.ts utility (src/lib/server/dataSource.ts) provides typed helpers β fromDatabase, fromFallback, fromDatabaseError, combineDataSources β for pages that want a richer status object (e.g. the DatabaseStatus indicator at the top of the page). The Editor itself only needs the boolean.
Focus Trapping
Because Editor is a modal, it must own focus while open. The implementation is the standard hand-rolled trap (same pattern as Drawer):
- On mount, snapshot
document.activeElementand lock body scroll. requestAnimationFrameβ focus the first form field.- While open, intercept
TabandShift+Tabon the modal element. Find tabbables viabutton, input, textarea, select, [tabindex]:not([tabindex="-1"]). Wrap forwards from last β first, backwards from first β last. EscapecallsonCancel(), unlesssaving === true(don't let users abandon a write mid-flight).- On unmount, restore body overflow and focus the original element if it still exists.
When the user submits an invalid form, focus jumps to the first invalid input β the trap and the validation cooperate to make keyboard-only completion painless.
State Flow Diagram
ββββββββββββββββ
β CLOSED β parent: open = false
ββββββββ¬ββββββββ
β parent opens with mode + initialData
βΌ
ββββββββββββββββ
β OPENING β formData = { ...initialData }
β β errors = validateForm()
β β touched = {} (clean slate)
β β body.overflow lock + focus first field
ββββββββ¬ββββββββ
β
βΌ
ββββββββββββββββ
β EDITING β user types β touched[field]=true
β β $effect re-runs validateForm()
β β visibleErrors update
ββββββββ¬ββββββββ
β
ββββββββββββββββΌβββββββββββββββ¬βββββββββββββββ
β β β β
βΌ βΌ βΌ βΌ
[Cancel] [Submit if [Submit if [Escape]
invalid] valid]
β β β β
β β β β
β βΌ βΌ β
β mark all touched saving = true β
β focus first error onSave(data) β
β β β β
β βΌ βΌ β
β (stay open) βββββββββββββββ β
β β PARENT β β
β β POST/PUT to β β
β β /editor/api β β
β ββββββββ¬βββββββ β
β β β
β βββββββββββββββΌββββββββββ β
β β β β β
β 2xx 4xx/5xx 503 β
β close show toast showβ
β β stay open bannerβ
βΌ βΌ βΌ
ββββββββββββββββ
β CLOSING β cleanup: restore body overflow,
β β restore focus, onCancel?()
ββββββββ¬ββββββββ
βΌ
ββββββββββββββββ
β CLOSED β
ββββββββββββββββ
DATABASE BRANCH (server utility):
request ββΆ DATABASE_URL set?
β
βββββββββ΄βββββββββ
β yes β no
βΌ βΌ
SQL query read: return FALLBACK_DATA
β write: throw β API β 503
βΌ
success?
β
βββ΄βββ
β ok β err
βΌ βΌ
map log + return fallback (read)
log + return null (write)Props Reference
| Prop | Type | Default | Description |
|---|---|---|---|
mode |
'create' | 'edit' |
'create' |
Controls submit button label and which validation messaging applies. |
initialData |
Partial<EditorData> |
{} |
Pre-fills the form. In edit mode this should include id so the parent can route to PUT. |
usingDatabase |
boolean |
false |
When false, renders an in-modal warning that changes will not persist. Mirrors !!process.env.DATABASE_URL from the page loader. |
onSave |
(data: EditorData) => void | Promise<void> |
undefined |
Fires on valid submit. Parent decides whether to POST or PUT. The Editor does not perform any network call itself. |
onCancel |
() => void |
undefined |
Fires on Escape, backdrop click, or Cancel button. Parent flips its open flag. |
Keyboard
| Key | Action |
|---|---|
Tab / Shift+Tab |
Cycle focus, trapped inside the modal. |
Escape |
Cancels (unless saving). |
Enter (in single-line input) |
Advances to next field; submits on the final field. |
Edge Cases
| Situation | Behaviour |
|---|---|
DATABASE_URL not configured, user tries to create |
API returns 503; UI shows "Database not configured" banner. No silent success. |
DATABASE_URL set but Neon unreachable mid-write |
Server utility catches the error, logs, returns null. API returns 500. Modal stays open, parent surfaces a toast. |
| User edits a record then somebody else deletes it before they save | PUT goes through, UPDATE β¦ WHERE id = ? AND is_active = TRUE matches zero rows, returns null, API returns 404. Client should refresh the list and notify. |
| User submits with missing required fields | All fields marked touched at once; first invalid field is focused; Submit stays disabled until valid. |
| User refreshes the page after editing | Read path re-fetches via +page.server.ts β loadEditorDataFromDatabase. Persisted edits show; in-memory edits (no DB) are lost. Expected. |
| Two users editing the same record simultaneously | Last writer wins. The COALESCE partial-update means each PUT only overwrites the fields it sends, so non-overlapping edits coexist. Overlapping edits silently clobber. |
| Soft-deleted record referenced elsewhere | Reads filter is_active = TRUE, so it disappears from listings. The row remains in the table (audit trail, undelete possible). Hard delete is intentionally not exposed. |
| Image URL is reachable at submit time but goes 404 later | Editor only validates the URL shape, not its reachability. Display layer handles broken images. |
initialData.id missing in edit mode |
Parent's PUT will fail validation (400 from the API). Always pass id when editing. |
| Modal opens with no tabbable content (impossible in practice, but defensively) | Focus stays on the dialog container itself (tabindex="-1"); Escape still closes. |
| Server-side render | Mount effect short-circuits when typeof document === 'undefined'. No DOM access during SSR. |
Dependencies
- Svelte 5.x β
$state,$derived,$effect,$props. Validation is reactive via theJSON.stringify(formData)trick inside$effect. @neondatabase/serverlessβ the Neon HTTP driver, used in the server utility. Requires Node.js 20.x runtime (configured insvelte.config.js).$lib/server/dataSourceβ typed helpers for distinguishing database / fallback / error sources. Used by demo pages for theDatabaseStatusindicator.- Form sub-components β
TextField,TextareaField,SelectFieldfrom$lib/components/. These are the actual inputs; the Editor orchestrates them. $lib/typesβEditorData,EditorDataRow,EditorPropsinterfaces. Component types are camelCase, database types are snake_case.
File Structure
src/lib/components/Editor.svelte # modal UI + validation + focus trap
src/lib/components/Editor.md # this file
src/lib/components/Editor.test.ts # unit tests
src/lib/components/{TextField,TextareaField,SelectField}.svelte # form atoms
src/lib/server/editorData.ts # CRUD server utility (reads + writes)
src/lib/server/dataSource.ts # typed source-status helpers
src/lib/constants.ts # FALLBACK_EDITOR_DATA
src/lib/types.ts # EditorData / EditorDataRow / EditorProps
src/routes/editor/+page.svelte # demo page (list + open Editor)
src/routes/editor/+page.server.ts # SSR load: folders + usingDatabase flag
src/routes/editor/api/+server.ts # GET / POST / PUT / DELETE
database/schema_editor.sql # editor_data table + trigger + seedAPI
04| Prop | Type | Default | Description |
|---|---|---|---|
mode | 'create' | 'edit' | 'create' | Modal title, submit-button label, and validation messaging swap based on mode. |
initialData | Partial<EditorData> | {} | Pre-fills the form when editing an existing item. |
usingDatabase | boolean | false | Toggles a copy hint that warns when changes will not be persisted. |
onSave | (data: EditorData) => void | Promise<void> | β | Called with the validated form payload. Async functions are awaited. |
onCancel | () => void | β | Fires when the user closes the modal via the cancel button or Escape key. |