Skip to main content

General

Emoji Reacts code

Copy the implementation or inspect every file that belongs to this component unit.

resources/views/components/ui/emoji-reacts.blade.php

@props([
    'emojis' => null,
    'reactions' => [],
    'label' => 'React',
    'align' => 'start',
    'name' => null,
])

@php
    /*
    | Self-contained emoji reactions with an open/dropdown picker menu.
    | Unlike the comment-scoped reactions component, this does not require a
    | comments parent Alpine scope.
    |
    | Pass `reactions` as [{ emoji, count, reacted? }, ...] for initial state.
    | Selecting an emoji toggles it locally and dispatches `emoji-react`.
    |
    | Usage:
    |   <x-ui.emoji-reacts :reactions="[
    |       ['emoji' => '👍', 'count' => 12, 'reacted' => true],
    |       ['emoji' => '❤️', 'count' => 4],
    |   ]" />
    */
    $emojiList = collect($emojis ?? \App\ReactionEmoji::values())
        ->map(fn ($emoji) => (string) $emoji)
        ->filter()
        ->unique()
        ->values()
        ->all();

    $initialReactions = collect($reactions)
        ->map(function ($row) {
            if (is_string($row)) {
                return [
                    'emoji' => $row,
                    'count' => 1,
                    'reacted' => false,
                ];
            }

            $emoji = (string) data_get($row, 'emoji', '');

            if ($emoji === '') {
                return null;
            }

            return [
                'emoji' => $emoji,
                'count' => max(0, (int) data_get($row, 'count', 0)),
                'reacted' => (bool) data_get($row, 'reacted', false),
            ];
        })
        ->filter()
        ->values()
        ->all();

    $align = in_array($align, ['start', 'end'], true) ? $align : 'start';
    $menuAlignClass = $align === 'end' ? 'end-0' : 'start-0';
@endphp

<div
    x-data="{
        open: false,
        name: @js($name),
        emojis: @js($emojiList),
        reactions: @js($initialReactions),
        toggleMenu() {
            this.open = ! this.open;
        },
        closeMenu() {
            this.open = false;
        },
        findIndex(emoji) {
            return this.reactions.findIndex((row) => row.emoji === emoji);
        },
        toggle(emoji) {
            const index = this.findIndex(emoji);
            let reacted = false;

            if (index === -1) {
                this.reactions.push({ emoji, count: 1, reacted: true });
                reacted = true;
            } else {
                const row = this.reactions[index];
                reacted = ! row.reacted;
                row.reacted = reacted;
                row.count = Math.max(0, row.count + (reacted ? 1 : -1));

                if (row.count === 0) {
                    this.reactions.splice(index, 1);
                }
            }

            this.closeMenu();
            this.$dispatch('emoji-react', {
                emoji,
                reacted,
                reactions: this.reactions,
                name: this.name,
            });
        },
    }"
    @click.outside="closeMenu()"
    @keydown.escape.window="closeMenu()"
    {{ $attributes->merge(['class' => 'relative inline-flex max-w-full flex-wrap items-center gap-1.5']) }}
>
    <template x-for="reaction in reactions" :key="reaction.emoji">
        <button
            type="button"
            @click="toggle(reaction.emoji)"
            :class="reaction.reacted
                ? 'border-brand-200 bg-brand-50 text-brand-700 dark:border-brand-800 dark:bg-brand-950/40 dark:text-brand-300'
                : 'border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300'"
            class="inline-flex min-h-9 items-center gap-1 rounded-full border px-2.5 py-1 text-xs font-medium transition hover:border-brand-300 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:hover:border-brand-700 sm:min-h-0 sm:px-2 sm:py-0.5"
            :aria-pressed="reaction.reacted.toString()"
            :aria-label="`React with ${reaction.emoji}`"
        >
            <span x-text="reaction.emoji" aria-hidden="true"></span>
            <span class="tabular-nums" x-text="reaction.count"></span>
        </button>
    </template>

    <div class="relative shrink-0">
        <button
            type="button"
            @click="toggleMenu()"
            class="inline-flex min-h-10 items-center gap-1.5 rounded-full border border-gray-200 bg-white px-3 text-xs font-medium text-gray-600 shadow-sm transition hover:border-brand-300 hover:text-brand-600 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:border-brand-700 dark:hover:text-brand-400 sm:h-8 sm:min-h-0 sm:px-2.5"
            :aria-expanded="open.toString()"
            aria-haspopup="menu"
            aria-label="{{ $label }}"
        >
            <x-ui.icon name="smiley" weight="bold" size="sm" />
            <span>{{ $label }}</span>
            <x-ui.icon name="caret-down" weight="bold" size="xs" class="opacity-60" />
        </button>

        <div
            x-show="open"
            x-cloak
            x-transition:enter="transition ease-out duration-100"
            x-transition:enter-start="opacity-0 scale-95"
            x-transition:enter-end="opacity-100 scale-100"
            x-transition:leave="transition ease-in duration-75"
            x-transition:leave-start="opacity-100 scale-100"
            x-transition:leave-end="opacity-0 scale-95"
            role="menu"
            aria-label="{{ $label }}"
            class="absolute {{ $menuAlignClass }} z-40 mt-2 grid max-w-[min(18rem,calc(100vw-2rem))] grid-cols-3 gap-1 rounded-xl border border-gray-200 bg-white p-2 shadow-lg ring-1 ring-black/5 dark:border-gray-800 dark:bg-gray-900 sm:flex sm:max-w-[min(22rem,calc(100vw-2rem))] sm:flex-wrap sm:gap-0.5 sm:p-1.5"
        >
            <template x-for="emoji in emojis" :key="emoji">
                <button
                    type="button"
                    role="menuitem"
                    @click="toggle(emoji)"
                    class="flex h-11 w-11 items-center justify-center rounded-lg text-xl transition hover:bg-gray-100 focus:outline-none focus-visible:ring-2 focus-visible:ring-brand-500 dark:hover:bg-gray-800 sm:h-9 sm:w-9 sm:text-lg"
                    :aria-label="`React with ${emoji}`"
                    x-text="emoji"
                ></button>
            </template>
        </div>
    </div>
</div>