Skip to content

Log

Follow a record's history: who changed what and when, with complete old and new values shown together.

  1. Today · Tue, Oct 27
    1. Anna declined

      StatusWaitingbecameDeclined

      Captions run a second late after 5:37.

    2. Captions Bureau uploaded Captions

  2. Yesterday · Mon, Oct 26
    1. Oleg commented

      Please check the second half, the speaker changes.

    2. Riverside set

      StatusWaiting
    3. System created the record

<script setup lang="ts">import { computed } from 'vue'import type { LogEvent } from 'grani'import { UiCard, UiLog, useToday } from 'grani' const HISTORY: LogEvent[] = [  {    id: 9,    at: '27 Sep, 13:21',    kind: 'decision',    tone: 'danger',    actor: { name: 'Anna' },    action: 'declined',    changes: [{ field: 'Status', from: 'Waiting', to: 'Declined', tone: 'danger' }],    note: 'Captions run a second late after 5:37.',  },  {    id: 8,    at: '27 Sep, 13:18',    kind: 'change',    actor: { name: 'Anna' },    action: 'changed',    changes: [      { field: 'Format', from: 'Portrait', to: 'Landscape' },      { field: 'Price', from: '$2.49', to: '$3.99' },      { field: 'Priority', from: '5', to: '1' },    ],  },  {    id: 7,    at: '27 Sep, 10:15',    kind: 'file',    actor: { name: 'Captions Bureau' },    action: 'replaced',    target: 'Captions',    changes: [{ field: 'File', from: 'clip_99776_v1.srt', to: 'clip_99776_v2.srt' }],  },  {    id: 6,    at: '27 Sep, 09:33',    kind: 'file',    actor: { name: 'Captions Bureau' },    action: 'uploaded',    target: 'Captions',  },  {    id: 5,    at: '26 Sep, 18:40',    kind: 'comment',    actor: { name: 'Oleg' },    action: 'commented',    note: 'Please check the second half, the speaker changes.',  },  {    id: 4,    at: '26 Sep, 18:10',    kind: 'change',    actor: { name: 'Riverside' },    action: 'set',    changes: [{ field: 'Status', to: 'Waiting', tone: 'warning' }],  },  { id: 3, at: '26 Sep, 18:08', kind: 'system', action: 'created the record' },] const PLAIN = HISTORY.slice(0, 1).concat(HISTORY.slice(3)) // The same history with real times, counted back from the showcase's today (27 Sep is today, the// 26th yesterday): events with a Date stand under their days.const { now } = useToday() // A time on today's date, moved by whole days. `new Date` reads it in the browser's zone; an app// with `createGrani({ timeZone })` builds its Dates in that zone, or takes them from the server.const on = (days: number, hhmm: string) => {  const at = new Date(`${now.value}T${hhmm}:00`)  at.setDate(at.getDate() + days)  return at} const dated = (list: LogEvent[]) =>  computed(() =>    list.map((e) => {      const [, day, hhmm] = /^(\d+) \w+, (\d\d:\d\d)$/.exec(String(e.at)) ?? []      return { ...e, at: on(Number(day) - 27, hhmm!) }    }),  ) const PLAIN_DATED = dated(PLAIN)</script> <template>  <UiCard class="w-full max-w-md">    <UiLog :items="PLAIN_DATED" aria-label="History" />  </UiCard></template>

Also called timeline, activity log, history, audit log.

Usage

<UiLog :items="items" />

With GraniResolver in the Vite config, a template needs no import: set it up once.

API

Name · typeDefaultDescription
Value
filterstring= """"The kind that is on: an event kind, or '' for all.
Data
items*LogEvent[]The events, newest first.
State
emptystring= """"The line when there are none; defaults to "No activity yet".
loadingboolean= falsefalseThe first load is on its way: rows in the shape of events, nothing else.
Behavior
nodedot | actor= "dot""dot"dot: a dot in the event's tone. actor: the face of who did it, the system a plate with a glyph.
filters{ value: string; label: string; }[]= [][]Kinds to choose from over the list, matched against each event's kind; "All" comes first. Up to three stand as segments, more go into a select.
hasMoreboolean= falsefalseOlder events exist: the list ends in "Load older events", which calls @more.
onMore(() => unknown)"Load older events" (@more): load the events before the last one. Return a promise to hold the button while it runs and to say a failure; nothing returned, it is not held.
failedboolean= falsefalseThe first load failed: says so, with Retry.
onRetry(() => unknown)Retry (@retry): load the history again. Return a promise to hold the button while it runs.
Form and access
ariaLabelstring= """"The list's name for a screen reader, when no heading names it.

Attributes: class and style stay on the outer element; the rest (native attributes, aria-*, data-testid) reach the control inside it.

When to use another component

When you needTake
The facts of a recordList item rows in a List
A list of recordsTable
A request answeredA toast of Toaster
A timeline the person editsNever; it only tells