Skip to main content

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>