Crud drawer
Edit a record beside its list and open related lists within the same panel. Built on Drawer.
<script setup lang="ts">import { computed, nextTick, ref } from 'vue'import { Archive, Trash2 } from '@lucide/vue'import type { LogEvent } from 'grani'import { UiCard, UiList, UiListItem, UiBadge, UiSwitch, UiCrudDrawer, UiCopyButton, UiSkeleton, UiListField, UiAvatar, UiHeader, UiField, UiInput, UiSelect, UiLog, UiToaster, UiConfirm, useConfirm, useToast } from 'grani' interface Client { id: string since: number active: boolean name: string legal: string tax: string email: string phone: string address: string city: string postcode: string country: string plan: string terms: string currency: string billing: string autoInvoice: boolean vat: boolean portal: boolean weekly: boolean} interface Contact { id: string name: string email: string} interface Invoice { id: string name: string date: string total: string paid: boolean} type CrudLevel = 'contacts' | 'invoices' const matching = <T extends { name: string }>(rows: T[], typed: string): T[] => { const q = typed.trim().toLowerCase() return q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows} const { confirm } = useConfirm() const toast = useToast() // A long record: sections of a few fields each, settings as rows, the actions last, then what happened.const COUNTRIES = [ { value: 'US', label: 'United States' }, { value: 'CA', label: 'Canada' }, { value: 'GB', label: 'United Kingdom' }, { value: 'DE', label: 'Germany' },] const PLANS = [ { value: 'solo', label: 'Solo · monthly' }, { value: 'team', label: 'Team · monthly' }, { value: 'team-year', label: 'Team · yearly' },] const TERMS = [ { value: '0', label: 'On receipt' }, { value: '14', label: 'Net 14' }, { value: '30', label: 'Net 30' },] const MONEY = [ { value: 'USD', label: 'USD' }, { value: 'EUR', label: 'EUR' }, { value: 'GBP', label: 'GBP' },] const CLIENT: Omit<Client, 'id' | 'since' | 'active'> = { name: 'Northwind Studio', legal: 'Northwind Studio LLC', tax: 'US 84-2031187', email: 'hello@northwind.example', phone: '+1 415 555 0142', address: '214 Harbor Street, suite 3', city: 'San Francisco', postcode: '94107', country: 'US', plan: 'team', terms: '30', currency: 'USD', billing: 'billing@northwind.example', autoInvoice: true, vat: false, portal: true, weekly: false,} const FIRST: Client = { ...CLIENT, id: 'CL-2041', since: 2024, active: true } const STUDIOS = ref<Client[]>([ FIRST, { ...CLIENT, id: 'CL-2036', since: 2023, active: true, name: 'Birch & Co', legal: 'Birch & Co Ltd', email: 'studio@birch.example', city: 'Portland', postcode: '97205', billing: 'accounts@birch.example', }, { ...CLIENT, id: 'CL-2019', since: 2022, active: false, name: 'Cedar Lane Films', legal: 'Cedar Lane Films Inc', email: 'hi@cedarlane.example', city: 'Vancouver', postcode: 'V6B 1A1', country: 'CA', currency: 'EUR', billing: 'billing@cedarlane.example', }, { ...CLIENT, id: 'CL-2007', since: 2021, active: true, name: 'Harbor Lights', legal: 'Harbor Lights GmbH', email: 'kontakt@harborlights.example', city: 'Hamburg', postcode: '20457', country: 'DE', currency: 'EUR', billing: 'rechnung@harborlights.example', },]) const studioAt = ref(0) const CLIENT_LOG: LogEvent[] = [ { id: 2, at: '27 Sep, 11:02', actor: { name: 'Anna Lee' }, action: 'changed', changes: [{ field: 'Terms', from: 'Net 14', to: 'Net 30' }], }, { id: 1, at: '12 Mar 2024, 09:15', actor: { name: 'Anna Lee' }, action: 'created the client' },] const open = ref(false) // What the client holds: its contacts and its invoices, each a list level of the same drawer.const contacts = ref<Contact[]>([ { id: 'c1', name: 'Anna Lee', email: 'anna@northwind.example' }, { id: 'c2', name: 'Ben Ortiz', email: 'ben@northwind.example' }, { id: 'c3', name: 'Chloe Park', email: 'chloe@northwind.example' }, { id: 'c4', name: 'Dan Moore', email: 'billing@northwind.example' },]) const clientInvoices = ref<Invoice[]>( Array.from({ length: 12 }, (_, k) => ({ id: `INV-${2041 - k}`, name: `INV-${2041 - k}`, date: `${28 - k * 2} Sep`, total: `$${(1240 - k * 85).toLocaleString('en-US')}.00`, paid: k > 1, })),) const crudAt = ref<CrudLevel[]>([]) const crudQuery = ref('') const crudLevel = computed(() => crudAt.value[0] ?? 'record') const crudContacts = computed(() => matching(contacts.value, crudQuery.value)) const crudInvoices = computed(() => matching(clientInvoices.value, crudQuery.value)) function crudStep(level: CrudLevel) { crudQuery.value = '' crudAt.value = [level]} function crudBack() { crudQuery.value = '' crudAt.value = []} function crudAdd(names: string[]) { if (crudLevel.value === 'contacts') contacts.value.push(...names.map((name) => ({ id: name, name, email: '' }))) else clientInvoices.value.unshift( ...names.map((name) => ({ id: name, name, date: 'Draft', total: '$0.00', paid: false })), ) light(names)} const loading = ref(false) const loadFailed = ref(false) const submitting = ref(false) const loadFails = ref(false) const saveFails = ref(false) const form = ref<Client>({ ...FIRST }) const saved = ref<Client>({ ...FIRST }) // A record still loading holds no input of the person's: nothing to guard yet.const dirty = computed( () => !loading.value && JSON.stringify(form.value) !== JSON.stringify(saved.value),) // The server's answer after a moment; the switches say how it goes.const later = (ms: number, fails: boolean) => new Promise<void>((resolve, reject) => setTimeout(() => (fails ? reject(new Error('offline')) : resolve()), ms), ) async function load() { loading.value = true loadFailed.value = false try { await later(600, loadFails.value) form.value = { ...saved.value } } catch { loadFailed.value = true } loading.value = false} function edit(i: number) { const studio = STUDIOS.value[i] if (!studio) return studioAt.value = i saved.value = { ...studio } crudAt.value = [] open.value = true void load()} // A failed save keeps the drawer and what was typed; a toast says why.async function save() { submitting.value = true try { await later(900, saveFails.value) saved.value = { ...form.value } STUDIOS.value[studioAt.value] = { ...form.value } open.value = false toast.success('Client saved') } catch { toast.error('Could not save the client', { description: 'Your changes are still here.' }) } submitting.value = false} // Delete cannot come back: it asks first and sends. The request itself closes the drawer and,// a tick later, takes the row, so a Retry from the toast of a failure does both too.function remove() { const id = saved.value.id void confirm({ title: `Delete ${saved.value.name}?`, message: 'The client and their invoices are removed. This cannot be undone.', confirmLabel: 'Delete', tone: 'danger', action: async () => { await later(800, false) open.value = false await nextTick() STUDIOS.value = STUDIOS.value.filter((s) => s.id !== id) }, success: 'Client deleted', })} const lit = ref<string[]>([]) let dim: ReturnType<typeof setTimeout> | undefined // A row lights for a moment when it comes, or when a repeat points at it.function light(names: string[]) { clearTimeout(dim) lit.value = names dim = setTimeout(() => (lit.value = []), 1200)} // What was added here stands on top, until the level is left.const onTop = <T extends { name: string }>(rows: T[], fresh: string[]): T[] => [ ...rows.filter((r) => fresh.includes(r.name)), ...rows.filter((r) => !fresh.includes(r.name)),]</script> <template> <div class="flex w-full max-w-sm flex-col items-center gap-4"> <UiCard :padded="false" class="w-full"> <UiList> <UiListItem v-for="(studio, i) in STUDIOS" :key="studio.id" as="button" size="sm" :title="studio.name" :description="`${studio.id} · ${studio.city}`" @click="edit(i)" > <template #actions> <UiBadge variant="inline" size="sm" :tone="studio.active ? 'success' : 'neutral'" :label="studio.active ? 'Active' : 'Paused'" /> </template> </UiListItem> </UiList> </UiCard> <div class="flex gap-6"> <UiSwitch v-model="loadFails">The load fails</UiSwitch> <UiSwitch v-model="saveFails">The save fails</UiSwitch> </div> </div> <UiCrudDrawer v-model:open="open" :divided="false" :title=" crudLevel === 'contacts' ? 'Contacts' : crudLevel === 'invoices' ? 'Invoices' : saved.name " :path="crudAt.length ? [saved.name] : []" :readonly="crudAt.length > 0" :loading="loading" :submitting="submitting" :error="loadFailed ? 'The server did not answer. Check the connection and try again.' : null" :index="studioAt" :total="STUDIOS.length" :persistent="dirty && !crudAt.length" :persistent-above="dirty" @submit="save" @back="crudBack" @retry="load" @move="edit" > <template v-if="!crudAt.length" #description> <UiBadge variant="inline" size="sm" :tone="saved.active ? 'success' : 'neutral'" :label="saved.active ? 'Active' : 'Paused'" /> <span aria-hidden="true">·</span> <UiCopyButton :value="saved.id" :truncate="12" variant="inline" /> <span aria-hidden="true">·</span> <span>client since {{ saved.since }}</span> </template> <template #loading> <div v-for="n in [3, 4]" :key="n" class="flex flex-col gap-4"> <div class="text-base"><UiSkeleton class="inline-block h-4 w-20 align-middle" /></div> <div v-for="k in n" :key="k" class="flex flex-col gap-2"> <div class="text-sm"><UiSkeleton class="inline-block h-3 w-16 align-middle" /></div> <UiSkeleton class="h-9 w-full" /> </div> </div> </template> <UiCard v-if="crudAt.length"> <UiListField v-model:query="crudQuery" :placeholder=" crudLevel === 'contacts' ? 'Find or add a contact' : 'Find an invoice or add a draft' " :empty=" crudLevel === 'contacts' ? 'No contacts yet: type a name and press Enter.' : 'No invoices yet.' " :existing="(crudLevel === 'contacts' ? contacts : clientInvoices).map((r) => r.name)" @add="crudAdd" @taken="(name) => light([name])" > <template #default="{ added: fresh }"> <template v-if="crudLevel === 'contacts'"> <UiListItem v-for="c in onTop(crudContacts, fresh)" :key="c.id" size="sm" :title="c.name" :description="c.email || 'No email yet'" :highlight="crudQuery" :lit="lit.includes(c.name)" > <template #media><UiAvatar :name="c.name" size="xs" /></template> </UiListItem> </template> <template v-else> <UiListItem v-for="inv in onTop(crudInvoices, fresh)" :key="inv.id" size="sm" :title="inv.name" :description="inv.date" :highlight="crudQuery" :lit="lit.includes(inv.name)" > <template #actions> <span class="text-sm tabular-nums text-fg">{{ inv.total }}</span> <UiBadge variant="inline" size="sm" :tone="inv.paid ? 'success' : 'warning'" :label="inv.paid ? 'Paid' : 'Due'" /> </template> </UiListItem> </template> </template> </UiListField> </UiCard> <template v-else> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="Profile" /> <UiField label="Name" ><UiInput v-model="form.name"> <template #end ><UiCopyButton :value="form.name" variant="ghost" size="xs" ariaLabel="Copy the name" /></template> </UiInput ></UiField> <UiField label="Legal name" ><UiInput v-model="form.legal"> <template #end ><UiCopyButton :value="form.legal" variant="ghost" size="xs" ariaLabel="Copy the legal name" /></template> </UiInput ></UiField> <UiField label="Tax ID" ><UiInput v-model="form.tax"> <template #end ><UiCopyButton :value="form.tax" variant="ghost" size="xs" ariaLabel="Copy the tax ID" /></template> </UiInput ></UiField> </div> </UiCard> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="Contact" /> <UiField label="Email" ><UiInput v-model="form.email" type="email"> <template #end ><UiCopyButton :value="form.email" variant="ghost" size="xs" ariaLabel="Copy the email" /></template> </UiInput ></UiField> <UiField label="Phone" ><UiInput v-model="form.phone" type="tel"> <template #end ><UiCopyButton :value="form.phone" variant="ghost" size="xs" ariaLabel="Copy the phone" /></template> </UiInput ></UiField> <UiField label="Address" ><UiInput v-model="form.address"> <template #end ><UiCopyButton :value="form.address" variant="ghost" size="xs" ariaLabel="Copy the address" /></template> </UiInput ></UiField> <div class="grid grid-cols-[2fr_1fr] gap-4"> <UiField label="City" ><UiInput v-model="form.city"> <template #end ><UiCopyButton :value="form.city" variant="ghost" size="xs" ariaLabel="Copy the city" /></template> </UiInput ></UiField> <UiField label="Postcode" ><UiInput v-model="form.postcode"> <template #end ><UiCopyButton :value="form.postcode" variant="ghost" size="xs" ariaLabel="Copy the postcode" /></template> </UiInput ></UiField> </div> <UiField label="Country" ><UiSelect v-model="form.country" :items="COUNTRIES" /></UiField> </div> </UiCard> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="Billing" /> <UiField label="Plan"><UiSelect v-model="form.plan" :items="PLANS" /></UiField> <div class="grid grid-cols-2 gap-4"> <UiField label="Terms"><UiSelect v-model="form.terms" :items="TERMS" /></UiField> <UiField label="Currency" ><UiSelect v-model="form.currency" :items="MONEY" /></UiField> </div> <UiField label="Billing email" ><UiInput v-model="form.billing" type="email"> <template #end ><UiCopyButton :value="form.billing" variant="ghost" size="xs" ariaLabel="Copy the billing address" /></template> </UiInput ></UiField> <UiCard :padded="false"> <UiList> <UiListItem as="label" size="sm" title="Send invoices automatically"> <template #actions><UiSwitch v-model="form.autoInvoice" /></template> </UiListItem> <UiListItem as="label" size="sm" title="Charge VAT"> <template #actions><UiSwitch v-model="form.vat" /></template> </UiListItem> </UiList> </UiCard> </div> </UiCard> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="Settings" /> <UiCard :padded="false"> <UiList> <UiListItem as="label" size="sm" title="Portal access" description="The client signs in to see invoices." > <template #actions><UiSwitch v-model="form.portal" /></template> </UiListItem> <UiListItem as="label" size="sm" title="Weekly report" description="A summary every Monday." > <template #actions><UiSwitch v-model="form.weekly" /></template> </UiListItem> </UiList> </UiCard> </div> </UiCard> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="Actions" /> <UiCard :padded="false"> <UiList> <UiListItem as="button" size="sm" step title="Contacts" @click="crudStep('contacts')" > <template #actions ><span class="text-sm text-fg-muted">{{ contacts.length }}</span></template > </UiListItem> <UiListItem as="button" size="sm" step title="Invoices" @click="crudStep('invoices')" > <template #actions ><span class="text-sm text-fg-muted">{{ clientInvoices.length }}</span></template > </UiListItem> <UiListItem as="button" size="sm" title="Archive" @click="toast.success(`${saved.name} archived`)" > <template #actions><Archive /></template> </UiListItem> <UiListItem as="button" size="sm" title="Delete the client" tone="danger" @click="remove" > <template #actions><Trash2 /></template> </UiListItem> </UiList> </UiCard> </div> </UiCard> <UiCard> <div class="flex flex-col gap-4"> <UiHeader variant="legend" title="History" /> <UiLog :items="CLIENT_LOG" aria-label="History" /> </div> </UiCard> </template> </UiCrudDrawer> <!-- Once, at the app's root: leave out what the app has there already. --> <UiToaster /> <UiConfirm /></template>
Also called edit panel, record drawer, side form, detail panel.
Usage
<UiCrudDrawer :title="title">…</UiCrudDrawer> With GraniResolver in the Vite config, a template needs no import: set it up once.
API
| Name · type | Default | Description |
|---|---|---|
| Value | ||
| openboolean= false | false | |
| State | ||
| loadingboolean= false | false | The record is still loading: a skeleton in the body, Save held. |
| submittingboolean= false | false | Saving: a spinner on Save, Cancel held, and no way to close until it ends. |
| errorstring | null= null | null | Why the record could not be loaded, in words for the reader: the body says it with Retry (retry), the header keeps the title alone, Save is held and nothing is guarded. |
| Behavior | ||
| title*string | The record's name, the dialog's name for a screen reader: "Santa", not "Edit operation". | |
| descriptionstring= "" | "" | What the record is and a detail: "operation · 31 Aug". |
| submitDisabledboolean= false | false | Save is held: nothing to save yet, or the form cannot be sent for a reason of your own; a load that failed is error. |
| persistentboolean= false | false | Unsaved input on this level: the backdrop and Escape are refused with a shake; Cancel and the cross still close. |
| persistentAboveboolean= false | false | At a step: a level above holds unsaved input. The cross, which closes them all, shakes instead; ‹, Escape and Cancel go one up as ever. |
| submitLabelstring= "" | "" | Save's label; defaults to "Save". |
| saveAnotherboolean= false | false | A menu on Save with "Save and add another", for a batch of new records: the parent saves (setting submitting while it does), clears the form and keeps the drawer open; once the form changed from what was sent, the cursor goes to its first field, a default kept in it or not. |
| cancelLabelstring= "" | "" | Cancel's label; defaults to "Cancel". |
| pathstring[]= [] | [] | The levels above this one, from the top: ['Groceries', 'Fruit']. Set, the header shows ‹, named by the last. |
| indexnumber= 0 | 0 | The record's place in the list it was opened from, from 0, across its pages; with total, the header walks the list. |
| totalnumber= 0 | 0 | How many records the list holds: two or more, the header of the record shows ↑ ↓, which emit move; a step shows none. |
| dividedboolean= true | true | A line between neighboring sections, the default; false parts them by their titles and the gap: a long form of different parts. |
| Look | ||
| sizemd | lg= "md" | "md" | The panel's width: md 512px for a record, the default; lg 768px for what reads wide, a text or a code. |
| Form and access | ||
| readonlyboolean= false | false | Nothing on this level waits for Save, every row goes at once: the footer holds only the way back; at the top there is no footer, the cross closes. |
Attributes are placed by the component itself: its description says where.
When to use another component
| When you need | Take |
|---|---|
| A panel to read or to set at once, with no form | Drawer |
| A short task that is not a record | Modal |
| A question about the record (delete it?) | Confirm above the drawer |
| A second drawer over the first | Never; in Crud drawer a record inside a record is a step of the same drawer (path) |