Skip to content
Brok UI

Loading…

No results

Activity Timeline

Open source

An audit/activity feed with filter chips carrying counts, a before→after diff cell for changes, a per-filter empty state, an export action, and a permission-denied state distinct from empty.

Version
v1.0.1
Stability
stable
License
MIT
Related
Empty
Badge

Preview

  1. Changed status to Active.

    Draft Active

    Today, 09:14, Priya Shah, Status

  2. Updated the price.

    €79.00 €89.00

    Yesterday, 16:02, Priya Shah, Change

  3. Confirmed with the supplier — restock ETA next week.

    Yesterday, 11:47, Marcus Lee, Comment

  4. Set the SKU.

    — AUD-EARB-001

    2 days ago, System, Change

No events match this filter yet.

previews.components.admin-activity-timeline.default.blade.php Blade
<div class="w-full">
    <x-ui.admin.activity-timeline />
</div>

Installation

terminal
php artisan ui:add admin-activity-timeline

Note

This component ships an Alpine behavior module at resources/js/ui/admin-activity-timeline.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './admin-activity-timeline.js';

Registry contract

php artisan ui:add admin-activity-timeline writes only the files below. The CLI validates each file hash before writing and asks before it replaces a local change, unless you pass --force.

  • blade resources/views/components/ui/admin/activity-timeline.blade.php
Registry dependencies
badge button empty
Packages
composer: jml/brok:^0.2
npm: alpinejs

Use with AI

A brief for your coding agent: install command, usage, props, guidance and the rules. Copy it, or open a prompt about this component in an assistant.

admin-activity-timeline.md
# Brok UI: Activity Timeline (`admin-activity-timeline`)

An audit/activity feed with filter chips carrying counts, a before→after diff cell for changes, a per-filter empty state, an export action, and a permission-denied state distinct from empty.

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add admin-activity-timeline
```

## Usage

```blade
<div class="w-full">
    <x-ui.admin.activity-timeline />
</div>
```

## Props

- `filters` (array, default `[]`) — [['key','label','count']]. Counts are supplied by the host, never re-derived from `events`, so a filter can claim more than this feed currently renders. Empty falls back to a sample All/Status/Changes/Comments set.
- `activeFilter` (string, default `all`) — The filter key selected on load.
- `events` (array, default `[]`) — [['id','kind','kindLabel','text','before' => null,'after' => null,'at','actor','tone' => 'neutral']]. Already merged from every source and sorted on a raw timestamp by the host — never on a formatted date string. Empty falls back to 4 sample events.
- `canView` (bool, default `true`) — false renders the permission-denied state instead of the feed or the empty state.
- `deniedTitle` (string|null, default `null`) — Title for the permission-denied state. Defaults to a localized placeholder.
- `deniedBody` (string|null, default `null`) — Body for the permission-denied state.
- `emptyTitle` (string|null, default `null`) — Title for the feed-wide empty state (canView is true but events is []).
- `emptyBody` (string|null, default `null`) — Body for the feed-wide empty state.
- `emptyFilterBody` (string|null, default `null`) — Message shown when the active filter matches nothing currently rendered.
- `exportHref` (string|null, default `null`) — When set, renders an Export action in the toolbar.

## Use when

- Use to summarize, sequence, or present data so users can scan it quickly.
- A record needs a single merged, filterable trail of what happened to it (status changes, field edits, notes) rather than a raw table.
- Some events are field changes worth showing as a before→after diff, and viewing the trail can legitimately be denied to some viewers.

## Avoid when

- Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
- You need a dense, sortable, column-based audit table (actor/field/trace as real columns) rather than a readable feed — build a table instead.
- Every viewer of the record can always see every event — the permission-denied branch and its wiring are then dead weight.

## Anti-patterns

- Adding display ornament without informational value

## Rules

- Use the `<brok:admin-activity-timeline>` tag (or `<x-ui.admin-activity-timeline>`) in Blade; do not rewrite the component.
- Prefer the documented props and variants over utility-class overrides; when a utility must win, use the `!` important modifier.
- Use semantic design tokens (`bg-primary`, `text-muted-foreground`), never raw colour utilities.
- Keep the `data-slot` attributes; they are the styling and test hooks.

## Links

- Docs: https://brokui.dev/docs/components/admin-activity-timeline
- Registry JSON (files, props, contract): https://brokui.dev/r/open/admin-activity-timeline.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
filters array [] [['key','label','count']]. Counts are supplied by the host, never re-derived from `events`, so a filter can claim more than this feed currently renders. Empty falls back to a sample All/Status/Changes/Comments set.
activeFilter string all The filter key selected on load.
events array [] [['id','kind','kindLabel','text','before' => null,'after' => null,'at','actor','tone' => 'neutral']]. Already merged from every source and sorted on a raw timestamp by the host — never on a formatted date string. Empty falls back to 4 sample events.
canView bool true false renders the permission-denied state instead of the feed or the empty state.
deniedTitle string | null null Title for the permission-denied state. Defaults to a localized placeholder.
deniedBody string | null null Body for the permission-denied state.
emptyTitle string | null null Title for the feed-wide empty state (canView is true but events is []).
emptyBody string | null null Body for the feed-wide empty state.
emptyFilterBody string | null null Message shown when the active filter matches nothing currently rendered.
exportHref string | null null When set, renders an Export action in the toolbar.

Slots

Default Blade slot only.

Data slots

Stable hooks for CSS overrides and browser tests.

activity-timeline activity-timeline-chip activity-timeline-list button

Behavior

  • canView === false always wins: it renders the denied state even when events is non-empty, because denial is about visibility, not data.
  • An event with a non-null before or after renders a struck-through before value, an arrow, and the after value in the row beneath its text.
  • Filter chips toggle client-side via a plain Alpine x-data object (no separate JS file/registration) and carry their count as a <x-ui.badge>.
  • Filtering to a chip whose count is 0 among the currently rendered events shows a dedicated 'no events match this filter' message, distinct from the feed-wide empty state.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized, alpine.

Guidance

Scannable data display

Present data for rapid scanning.

Use when

  • Use to summarize, sequence, or present data so users can scan it quickly.
  • A record needs a single merged, filterable trail of what happened to it (status changes, field edits, notes) rather than a raw table.
  • Some events are field changes worth showing as a before→after diff, and viewing the trail can legitimately be denied to some viewers.

Avoid when

  • Do not add display-only ornament when the user needs actionable structure or exact comparison instead.
  • You need a dense, sortable, column-based audit table (actor/field/trace as real columns) rather than a readable feed — build a table instead.
  • Every viewer of the record can always see every event — the permission-denied branch and its wiring are then dead weight.

Use instead

  • Table for exact comparison
  • Plain text for a single value

Anti-patterns

  • Adding display ornament without informational value
Anatomy
activity-timeline activity-timeline-chip activity-timeline-list
Theming hooks
activity-timeline

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
managed
Focus
none
  • Meet the WCAG 2.2 AA target declared in meta.a11y.
  • Filter chips are native <button type="button"> elements with aria-pressed reflecting the active filter, grouped under role="group".
  • The permission-denied and empty states use <x-ui.empty> so they carry the same heading/description semantics as every other empty state in the system.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, so keyboard focus is always visible.
  • Disabled and invalid states are conveyed to assistive tech, not by color alone.
  • Targets WCAG 2.2 AA; verify contrast in light, dark, admin and customer surfaces in the preview.
  • Labels go through __() and layout uses logical properties (ms-*, text-start), so it mirrors under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="admin-activity-timeline-{{ $record->id }}">
    <div class="w-full">
        <x-ui.admin.activity-timeline />
    </div>
</div>

Source

The exact, editable file ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/admin/activity-timeline.blade.php Blade
{{--
    Activity Timeline — an audit/activity feed for a record: a merged,
    time-sorted trail of what happened (status changes, field edits, notes),
    filterable by kind, each filter chip carrying how many events it holds,
    with an export action and an honest denied state.

    ONE FEED, MERGED UPSTREAM
    ------------------------------------------------------------------
    `events` is the single, already-merged, already-sorted list a host
    assembles from however many read models it has (comments, field-change
    audit rows, system events, …) — this component does not merge or gate
    anything itself. Two lists resolved separately and then interleaved by a
    rendered date string is a bug waiting to happen: sort upstream on a raw
    instant, never on a formatted label.

    NOT SEEING IT IS NOT THE SAME AS THERE BEING NOTHING TO SEE
    ------------------------------------------------------------------
    `canView === false` renders a permission-denied state, never the empty
    state. A blank feed reads as "nothing has ever happened here"; a viewer
    without the right to see the audit trail should be told exactly that
    instead — the same distinction `orders/history.blade.php` insisted on.

    Filter chips carry counts the host already computed (never re-derived
    from `events` here — a chip can legitimately claim more than this feed
    currently renders), so filtering to a chip with nothing shown gets its
    own "nothing matches this filter" message rather than being confused
    with the feed-wide empty state.

    Generalises `orders/timeline.blade.php` (merged sources, filter chips
    with counts, export) and `orders/history.blade.php` (the before→after
    diff cell, folded into a feed row instead of a separate table, and the
    permission-denied state). Left out as domain logic: the Orders Query
    merge/permission rules, the `manage_orders` gate itself, and the
    correlation-id/rule-version audit columns — callers already resolve
    `events` before this renders.
--}}
@props([
    'filters' => [],
    'activeFilter' => 'all',
    'events' => [],
    'canView' => true,
    'deniedTitle' => null,
    'deniedBody' => null,
    'emptyTitle' => null,
    'emptyBody' => null,
    'emptyFilterBody' => null,
    'exportHref' => null,
])

@php
    // Shapes documented in item.json knowledge.props (kept single-line in
    // @props so the registry contract builder can parse each default verbatim):
    //   filters: [['key', 'label', 'count']] — first entry is conventionally 'all'.
    //   events: [['id', 'kind', 'kindLabel', 'text', 'before' => null, 'after' => null, 'at', 'actor', 'tone' => 'neutral']]
    $filters = $filters !== [] ? $filters : [
        ['key' => 'all', 'label' => 'All', 'count' => 4],
        ['key' => 'status', 'label' => 'Status', 'count' => 1],
        ['key' => 'change', 'label' => 'Changes', 'count' => 2],
        ['key' => 'comment', 'label' => 'Comments', 'count' => 1],
    ];
    $filters = array_values(array_filter((array) $filters, 'is_array'));

    $events = $events !== [] ? $events : [
        [
            'id' => 'evt-4', 'kind' => 'status', 'kindLabel' => 'Status',
            'text' => 'Changed status to Active.', 'before' => 'Draft', 'after' => 'Active',
            'at' => 'Today, 09:14', 'actor' => 'Priya Shah',
        ],
        [
            'id' => 'evt-3', 'kind' => 'change', 'kindLabel' => 'Change',
            'text' => 'Updated the price.', 'before' => '€79.00', 'after' => '€89.00',
            'at' => 'Yesterday, 16:02', 'actor' => 'Priya Shah',
        ],
        [
            'id' => 'evt-2', 'kind' => 'comment', 'kindLabel' => 'Comment',
            'text' => 'Confirmed with the supplier — restock ETA next week.',
            'at' => 'Yesterday, 11:47', 'actor' => 'Marcus Lee',
        ],
        [
            'id' => 'evt-1', 'kind' => 'change', 'kindLabel' => 'Change',
            'text' => 'Set the SKU.', 'before' => '—', 'after' => 'AUD-EARB-001',
            'at' => '2 days ago', 'actor' => 'System',
        ],
    ];
    $events = array_values(array_filter((array) $events, 'is_array'));

    $deniedTitle ??= __("You don't have access to this activity");
    $deniedBody ??= __('Ask a workspace admin for permission to view the audit trail.');
    $emptyTitle ??= __('Nothing has happened yet');
    $emptyBody ??= __('Activity on this record will show up here.');
    $emptyFilterBody ??= __('No events match this filter yet.');

    $renderedKinds = array_values(array_unique(array_map(
        static fn (array $event): string => (string) ($event['kind'] ?? ''),
        $events,
    )));
@endphp

<div
    data-slot="activity-timeline"
    data-surface="admin"
    x-data="{ filter: @js($activeFilter) }"
    {{ $attributes->merge(['class' => 'rounded-lg border border-border bg-card']) }}
>
    @if ($canView && ($filters !== [] || $exportHref))
        <div class="flex flex-wrap items-center justify-between gap-2 border-b border-border px-4 py-4">
            @if ($filters !== [])
                <div class="flex flex-wrap items-center gap-2" role="group" aria-label="{{ __('Filter activity') }}">
                    @foreach ($filters as $chip)
                        {{-- `contents` keeps this span out of the flex/gap layout above while
                             still giving the chip a stable, component-owned `data-slot` — the button
                             primitive already owns `data-slot="button"` on its own root, and a
                             second `data-slot` attribute on that same element would be a
                             duplicate the browser silently drops (first one wins). --}}
                        <span data-slot="activity-timeline-chip" class="contents">
                            <x-ui.button
                                variant="ghost"
                                size="sm"
                                shape="pill"
                                x-on:click="filter = '{{ $chip['key'] ?? 'all' }}'"
                                x-bind:aria-pressed="filter === '{{ $chip['key'] ?? 'all' }}' ? 'true' : 'false'"
                                x-bind:class="filter === '{{ $chip['key'] ?? 'all' }}'
                                    ? 'border-border-strong bg-secondary text-foreground'
                                    : 'border-border !text-muted-foreground hover:!bg-secondary !font-normal'"
                                class="border"
                            >
                                {{ $chip['label'] ?? '' }}
                                <x-ui.badge variant="soft-neutral" size="sm" class="ms-2 tabular-nums">{{ $chip['count'] ?? 0 }}</x-ui.badge>
                            </x-ui.button>
                        </span>
                    @endforeach
                </div>
            @endif

            @if ($exportHref)
                <x-ui.button variant="outline" size="sm" href="{{ $exportHref }}">
                    {{ __('Export') }}
                </x-ui.button>
            @endif
        </div>
    @endif

    @if (! $canView)
        <x-ui.empty size="sm">
            <x-ui.empty.header>
                <x-ui.empty.title>{{ $deniedTitle }}</x-ui.empty.title>
                <x-ui.empty.description>{{ $deniedBody }}</x-ui.empty.description>
            </x-ui.empty.header>
        </x-ui.empty>
    @elseif ($events === [])
        <x-ui.empty size="sm">
            <x-ui.empty.header>
                <x-ui.empty.title>{{ $emptyTitle }}</x-ui.empty.title>
                <x-ui.empty.description>{{ $emptyBody }}</x-ui.empty.description>
            </x-ui.empty.header>
        </x-ui.empty>
    @else
        <ol data-slot="activity-timeline-list" class="divide-y divide-border">
            @foreach ($events as $event)
                <li
                    class="flex gap-4 px-4 py-4"
                    x-show="filter === 'all' || filter === '{{ $event['kind'] ?? '' }}'"
                >
                    <span
                        @class([
                            'mt-2 size-2 shrink-0 rounded-full',
                            'bg-warning' => ($event['tone'] ?? null) === 'attention',
                            'bg-destructive' => ($event['tone'] ?? null) === 'negative',
                            'bg-border-strong' => ! in_array($event['tone'] ?? null, ['attention', 'negative'], true),
                        ])
                        aria-hidden="true"
                    ></span>

                    <div class="min-w-0 flex-1">
                        <p class="text-sm text-pretty text-foreground">{{ $event['text'] ?? '' }}</p>

                        @if (($event['before'] ?? null) !== null || ($event['after'] ?? null) !== null)
                            <p class="mt-0.5 text-xs">
                                <span class="text-muted-foreground line-through">{{ $event['before'] ?? '' }}</span>
                                <span aria-hidden="true" class="mx-2 text-faint-foreground">&rarr;</span>
                                <span class="font-medium text-foreground">{{ $event['after'] ?? '' }}</span>
                            </p>
                        @endif

                        <p class="mt-0.5 text-xs text-faint-foreground">
                            {{ $event['at'] ?? '' }}, {{ $event['actor'] ?? '' }}, {{ $event['kindLabel'] ?? ($event['kind'] ?? '') }}
                        </p>
                    </div>
                </li>
            @endforeach
        </ol>

        <p
            x-show="filter !== 'all' && ! {{ Illuminate\Support\Js::from($renderedKinds) }}.includes(filter)"
            x-cloak
            class="px-4 py-10 text-center text-sm text-pretty text-muted-foreground"
        >
            {{ $emptyFilterBody }}
        </p>
    @endif
</div>

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.0.1
Status
Stable
License
open
Accessibility reviewed
No review date recorded
Last breaking change
No date recorded
Deprecation
Not deprecated
Contract
v6
Foundation
≥ 1.0.0