Data display
Email List code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/email-list.blade.php
@props([
'title' => 'Inbox',
'items' => [],
'folders' => [],
'activeFolder' => 'inbox',
'selectedId' => null,
'search' => true,
'selectable' => true,
'labelColors' => [],
'emptyTitle' => 'No messages',
'emptyDescription' => 'Your mailbox is empty for this folder.',
])
@php
/*
| Mailbox message list with folder tabs, search, and selectable rows.
|
| Each item: id, from, fromEmail, subject, preview, time, unread,
| hasAttachment, labels (array of strings or ['name' => ..., 'color' => ...]),
| folder, href.
|
| labelColors: map of label name to colour variant. Supported variants match
| the Harbour badge palette — gray, brand, green, red, amber, blue.
|
| folders: [value => label] for the segmented control (e.g. inbox, sent).
|
| Usage:
| <x-ui.email-list
| title="Inbox"
| :folders="['inbox' => 'Inbox', 'sent' => 'Sent']"
| :label-colors="['Work' => 'blue', 'Design' => 'brand']"
| active-folder="inbox"
| :selected-id="$selectedId"
| :items="$messages"
| />
*/
$labelVariants = ['gray', 'brand', 'green', 'red', 'amber', 'blue'];
$labelDotClasses = [
'gray' => 'bg-gray-400 dark:bg-gray-500',
'brand' => 'bg-brand-500 dark:bg-brand-400',
'green' => 'bg-green-500 dark:bg-green-400',
'red' => 'bg-red-500 dark:bg-red-400',
'amber' => 'bg-amber-500 dark:bg-amber-400',
'blue' => 'bg-accent-500 dark:bg-accent-400',
];
$labelPillClasses = [
'gray' => 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
'brand' => 'bg-brand-50 text-brand-700 dark:bg-brand-950/60 dark:text-brand-300',
'green' => 'bg-green-50 text-green-700 dark:bg-green-950/60 dark:text-green-300',
'red' => 'bg-red-50 text-red-700 dark:bg-red-950/60 dark:text-red-300',
'amber' => 'bg-amber-50 text-amber-700 dark:bg-amber-950/60 dark:text-amber-300',
'blue' => 'bg-accent-50 text-accent-700 dark:bg-accent-950/60 dark:text-accent-300',
];
$resolveLabelColor = function (mixed $label) use ($labelColors, $labelVariants): array {
if (is_array($label)) {
$name = (string) ($label['name'] ?? $label['label'] ?? '');
$color = (string) ($label['color'] ?? $labelColors[$name] ?? 'gray');
} else {
$name = (string) $label;
$color = (string) ($labelColors[$name] ?? 'gray');
}
if (! in_array($color, $labelVariants, true)) {
$color = 'gray';
}
return ['name' => $name, 'color' => $color];
};
$normalized = collect($items)->map(function (mixed $item, int|string $key) use ($resolveLabelColor): array {
if (! is_array($item)) {
return [
'id' => (string) $key,
'from' => 'Unknown',
'fromEmail' => null,
'initials' => 'U',
'subject' => 'No subject',
'preview' => '',
'time' => '',
'unread' => false,
'hasAttachment' => false,
'labels' => [],
'folder' => 'inbox',
'href' => null,
'avatarBackground' => null,
];
}
$labels = collect($item['labels'] ?? [])
->map(fn (mixed $label): array => $resolveLabelColor($label))
->filter(fn (array $label): bool => $label['name'] !== '')
->values()
->all();
return [
'id' => (string) ($item['id'] ?? $key),
'from' => $item['from'] ?? 'Unknown',
'fromEmail' => $item['fromEmail'] ?? null,
'initials' => $item['initials'] ?? \Illuminate\Support\Str::of($item['from'] ?? 'U')->explode(' ')->take(2)->map(fn (string $part): string => mb_substr($part, 0, 1))->implode(''),
'subject' => $item['subject'] ?? 'No subject',
'preview' => $item['preview'] ?? '',
'time' => $item['time'] ?? '',
'unread' => (bool) ($item['unread'] ?? false),
'hasAttachment' => (bool) ($item['hasAttachment'] ?? false),
'labels' => $labels,
'folder' => (string) ($item['folder'] ?? 'inbox'),
'href' => $item['href'] ?? null,
'avatarBackground' => $item['avatarBackground'] ?? null,
];
})->values();
$folderOptions = $folders !== [] ? $folders : [
'inbox' => 'Inbox',
'sent' => 'Sent',
'drafts' => 'Drafts',
];
$ids = $normalized->pluck('id')->all();
@endphp
<section
x-data="{
_localFolder: @js((string) $activeFolder),
_localTags: [],
query: '',
selectable: @js((bool) $selectable),
selected: [],
items: @js($normalized),
dotClasses: @js($labelDotClasses),
pillClasses: @js($labelPillClasses),
allIds: @js($ids),
selectedId: @js($selectedId !== null ? (string) $selectedId : null),
get folder() {
return this.$store.mailFilters?.folder ?? this._localFolder;
},
setFolder(value) {
if (this.$store.mailFilters) {
this.$store.mailFilters.setFolder(value);
return;
}
this._localFolder = value;
},
get tagFilter() {
return this.$store.mailFilters?.tags ?? this._localTags;
},
get filtered() {
const folder = this.folder;
let list = folder === 'all'
? this.items.slice()
: this.items.filter((item) => item.folder === folder);
const activeTags = this.tagFilter;
if (activeTags.length > 0) {
list = list.filter((item) => item.labels.some((label) => activeTags.includes(label.name)));
}
if (this.query.trim()) {
const needle = this.query.trim().toLowerCase();
list = list.filter((item) => {
return [item.from, item.fromEmail, item.subject, item.preview]
.filter(Boolean)
.some((value) => value.toLowerCase().includes(needle));
});
}
return list;
},
dotClass(color) {
return this.dotClasses[color] || this.dotClasses.gray;
},
pillClass(color) {
return this.pillClasses[color] || this.pillClasses.gray;
},
toggleSelected(id) {
if (! this.selectable) {
return;
}
if (this.selected.includes(id)) {
this.selected = this.selected.filter((value) => value !== id);
} else {
this.selected.push(id);
}
},
toggleAll() {
if (! this.selectable) {
return;
}
const visibleIds = this.filtered.map((item) => item.id);
const allSelected = visibleIds.length > 0 && visibleIds.every((id) => this.selected.includes(id));
if (allSelected) {
this.selected = this.selected.filter((id) => ! visibleIds.includes(id));
} else {
this.selected = [...new Set([...this.selected, ...visibleIds])];
}
},
}"
{{ $attributes->merge(['class' => 'flex min-h-0 flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900']) }}
>
<div class="border-b border-gray-200 px-4 py-4 dark:border-gray-800 sm:px-5">
<div class="flex items-start justify-between gap-3">
<div class="min-w-0">
<h2 class="text-base font-semibold text-gray-900 dark:text-white">{{ $title }}</h2>
<p class="mt-0.5 text-sm text-gray-500 dark:text-gray-400" x-text="filtered.length + ' message' + (filtered.length === 1 ? '' : 's')"></p>
</div>
@isset($actions)
<div class="flex shrink-0 items-center gap-2">
{{ $actions }}
</div>
@endisset
</div>
@if ($folderOptions !== [])
<div class="mt-4 flex gap-1 overflow-x-auto rounded-xl bg-gray-100 p-1 dark:bg-gray-800/80">
@foreach ($folderOptions as $value => $label)
<button
type="button"
class="shrink-0 rounded-lg px-3 py-1.5 text-xs font-semibold transition sm:text-sm"
x-on:click="setFolder(@js((string) $value))"
x-bind:class="folder === @js((string) $value)
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-900 dark:text-white'
: 'text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200'"
>{{ $label }}</button>
@endforeach
</div>
@endif
@if ($search)
<div class="mt-4">
<x-ui.input
type="search"
size="sm"
icon="magnifying-glass"
placeholder="Search mail"
x-model="query"
/>
</div>
@endif
</div>
<div
x-show="selectable && selected.length > 0"
x-cloak
class="flex items-center gap-2 overflow-x-auto border-b border-gray-200 bg-gray-50 px-4 py-2 text-sm dark:border-gray-800 dark:bg-gray-950/40 sm:flex-wrap sm:px-5"
>
<span class="shrink-0 font-medium text-gray-700 dark:text-gray-200" x-text="selected.length + ' selected'"></span>
<x-ui.icon-button icon="archive" label="Archive" size="sm" variant="ghost" class="shrink-0 sm:hidden" />
<x-ui.button type="button" variant="ghost" size="sm" icon="archive" class="hidden shrink-0 sm:inline-flex">Archive</x-ui.button>
<x-ui.icon-button icon="trash" label="Delete" size="sm" variant="ghost" class="shrink-0 sm:hidden" />
<x-ui.button type="button" variant="ghost" size="sm" icon="trash" class="hidden shrink-0 sm:inline-flex">Delete</x-ui.button>
<x-ui.icon-button icon="envelope-open" label="Mark read" size="sm" variant="ghost" class="shrink-0 sm:hidden" />
<x-ui.button type="button" variant="ghost" size="sm" icon="envelope-open" class="hidden shrink-0 sm:inline-flex">Mark read</x-ui.button>
</div>
<div class="min-h-0 flex-1 overflow-y-auto">
<template x-if="filtered.length === 0">
<div class="flex h-full min-h-48 flex-col items-center justify-center px-6 py-10 text-center">
<span class="flex h-12 w-12 items-center justify-center rounded-2xl bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500">
<x-ui.icon name="envelope" weight="duotone" size="2xl" />
</span>
<p class="mt-4 text-sm font-semibold text-gray-900 dark:text-white">{{ $emptyTitle }}</p>
<p class="mt-1 max-w-xs text-sm text-gray-500 dark:text-gray-400">{{ $emptyDescription }}</p>
@isset($emptyActions)
<div class="mt-4">
{{ $emptyActions }}
</div>
@endisset
</div>
</template>
<ul class="divide-y divide-gray-100 dark:divide-gray-800" role="list">
<template x-for="item in filtered" :key="item.id">
<li>
<div
class="group flex items-start gap-2 px-3 py-3 transition sm:gap-3 sm:px-4"
:class="{
'bg-brand-50/70 dark:bg-brand-950/30': selectedId === item.id,
'bg-white hover:bg-gray-50 dark:bg-gray-900 dark:hover:bg-gray-800/60': selectedId !== item.id,
}"
>
<div class="flex shrink-0 flex-col items-center gap-2 pt-1">
<input
x-show="selectable"
type="checkbox"
class="hidden size-4 rounded border-gray-300 text-brand-600 focus:ring-brand-500 sm:inline-block dark:border-gray-600 dark:bg-gray-900"
:checked="selected.includes(item.id)"
x-on:change="toggleSelected(item.id)"
x-on:click.stop
>
<div
class="flex flex-col items-center gap-1"
:aria-label="item.labels.length ? item.labels.map((label) => label.name).join(', ') + ' labels' : 'No labels'"
x-show="item.labels.length > 0"
x-cloak
>
<template x-for="label in item.labels.slice(0, 3)" :key="item.id + '-dot-' + label.name">
<span
class="block h-1.5 w-1.5 rounded-full"
:class="dotClass(label.color)"
:title="label.name"
></span>
</template>
</div>
</div>
<a
:href="item.href || '#'"
class="flex min-w-0 flex-1 items-start gap-3"
x-on:click="selectedId = item.id"
>
<span
class="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full text-xs font-semibold text-white"
:style="item.avatarBackground ? `background-color: ${item.avatarBackground}` : null"
:class="! item.avatarBackground ? 'bg-gray-400 dark:bg-gray-600' : ''"
x-text="item.initials"
></span>
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-3">
<p
class="truncate text-sm"
:class="item.unread ? 'font-semibold text-gray-900 dark:text-white' : 'font-medium text-gray-700 dark:text-gray-200'"
x-text="item.from"
></p>
<div class="flex shrink-0 items-center gap-1.5">
<x-ui.icon
name="paperclip"
size="sm"
class="text-gray-400 dark:text-gray-500"
x-show="item.hasAttachment"
x-cloak
/>
<span class="text-xs text-gray-400 dark:text-gray-500" x-text="item.time"></span>
</div>
</div>
<p
class="mt-0.5 truncate text-sm"
:class="item.unread ? 'font-semibold text-gray-900 dark:text-white' : 'text-gray-800 dark:text-gray-100'"
x-text="item.subject"
></p>
<p class="mt-0.5 line-clamp-2 text-sm text-gray-500 sm:truncate dark:text-gray-400" x-text="item.preview"></p>
<div class="mt-2 hidden flex-wrap gap-1.5 sm:flex" x-show="item.labels.length > 0" x-cloak>
<template x-for="label in item.labels" :key="item.id + '-pill-' + label.name">
<span
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[0.6875rem] font-medium"
:class="pillClass(label.color)"
>
<span class="h-1.5 w-1.5 rounded-full" :class="dotClass(label.color)"></span>
<span x-text="label.name"></span>
</span>
</template>
</div>
</div>
</a>
</div>
</li>
</template>
</ul>
</div>
</section>