Skip to content

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 · typeDefaultDescription
Value
openboolean= falsefalse
State
loadingboolean= falsefalseThe record is still loading: a skeleton in the body, Save held.
submittingboolean= falsefalseSaving: a spinner on Save, Cancel held, and no way to close until it ends.
errorstring | null= nullnullWhy 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*stringThe 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= falsefalseSave 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= falsefalseUnsaved input on this level: the backdrop and Escape are refused with a shake; Cancel and the cross still close.
persistentAboveboolean= falsefalseAt 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= falsefalseA 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= 00The record's place in the list it was opened from, from 0, across its pages; with total, the header walks the list.
totalnumber= 00How many records the list holds: two or more, the header of the record shows ↑ ↓, which emit move; a step shows none.
dividedboolean= truetrueA 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= falsefalseNothing 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 needTake
A panel to read or to set at once, with no formDrawer
A short task that is not a recordModal
A question about the record (delete it?)Confirm above the drawer
A second drawer over the firstNever; in Crud drawer a record inside a record is a step of the same drawer (path)