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.
Preview
-
Changed status to Active.
Draft Active
Today, 09:14, Priya Shah, Status
-
Updated the price.
€79.00 €89.00
Yesterday, 16:02, Priya Shah, Change
-
Confirmed with the supplier — restock ETA next week.
Yesterday, 11:47, Marcus Lee, Comment
-
Set the SKU.
— AUD-EARB-001
2 days ago, System, Change
No events match this filter yet.
<div class="w-full">
<x-ui.admin.activity-timeline />
</div>
Installation
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:
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.
-
resources/views/components/ui/admin/activity-timeline.blade.php
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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
{{--
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">→</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