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>