Skip to content
Brok UI

Loading…

No results

Emoji Picker

Open source

A popover-style emoji picker with category tabs, search, and a grid of Unicode emoji characters. Integrates with the editor contract (dispatches editor-insert and emoji-select events).

Version
v1.2.3
Stability
stable
License
MIT
Related
Editor
Editor Toolbar
Color Dropdown

Preview

previews.components.emoji-picker.default.blade.php Blade
<div class="w-full flex justify-center p-6">
    <x-ui.emoji-picker />
</div>

Installation

terminal
php artisan ui:add emoji-picker

Note

This component ships an Alpine behavior module at resources/js/ui/emoji-picker.js. Import it once from your bundle so it registers on alpine:init:

resources/js/ui/index.js JS
import './emoji-picker.js';

Registry contract

php artisan ui:add emoji-picker 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.

  • blade resources/views/components/ui/emoji-picker.blade.php
  • js resources/js/ui/emoji-picker.js
Registry dependencies
input
Packages
composer: jml/brok:^0.2
npm: alpinejs

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.

emoji-picker.md
# Brok UI: Emoji Picker (`emoji-picker`)

A popover-style emoji picker with category tabs, search, and a grid of Unicode emoji characters. Integrates with the editor contract (dispatches editor-insert and emoji-select events).

Brok UI is a Laravel Blade component registry. Installed components are plain Blade files the app owns.

## Install

```bash
php artisan ui:add emoji-picker
```

## Usage

```blade
<div class="w-full flex justify-center p-6">
    <x-ui.emoji-picker />
</div>
```

## Props

- `placeholder` (string, default `Search emoji…`) — Placeholder text for the emoji search input.

## Use when

- Use when users need rich structured authoring, annotation, or collaborative editing controls.
- Letting a user search and insert a Unicode emoji, either into an <x-ui.editor> or as a standalone picker with a custom listener.

## Avoid when

- Do not use rich editor affordances for simple one-line or plain-text tasks.
- Custom image-based or brand emoji reactions are needed; this renders plain Unicode characters only.

## Anti-patterns

- Rich editors for one-line or easily validated input

## Rules

- Use the `<brok:emoji-picker>` tag (or `<x-ui.emoji-picker>`) 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/emoji-picker
- Registry JSON (files, props, contract): https://brokui.dev/r/open/emoji-picker.json

Working in Claude Code, Cursor or Codex? Give the agent the whole registry through the MCP server or the Brok UI skill.

API

manifest knowledge + registry-derived coverage

Props

Props accepted by this component: name, type, default value and description.
Prop Type Default Description
placeholder string Search emoji… Placeholder text for the emoji search input.

Slots

  • default —

Data slots

Stable hooks for CSS overrides and browser tests.

emoji-picker

Behavior

  • Choosing an emoji dispatches editor-insert { text, html } (html escaped), caught by an ancestor editor to insert at the caret as literal text, and emoji-select for standalone listeners.
  • The category strip and grid are both single tab stops that Arrow keys and Home/End walk internally, so Tab moves past the whole picker rather than through every emoji.
  • Installs a JavaScript behavior module when the registry item includes resources/js/ui files.
  • Declares registry capability flags: a11y, interactive, responsive, rtl, darkMode, localized.

Guidance

Rich authoring and editing

Create or revise structured rich content.

Use when

  • Use when users need rich structured authoring, annotation, or collaborative editing controls.
  • Letting a user search and insert a Unicode emoji, either into an <x-ui.editor> or as a standalone picker with a custom listener.

Avoid when

  • Do not use rich editor affordances for simple one-line or plain-text tasks.
  • Custom image-based or brand emoji reactions are needed; this renders plain Unicode characters only.

Use instead

  • Textarea or plain field for simple content

Anti-patterns

  • Rich editors for one-line or easily validated input
Anatomy
root
Theming hooks
emoji-picker

Accessibility

WCAG 2.2 AA Keyboard focus-visible RTL-ready Localized labels Dark mode
Keyboard
ArrowLeft ArrowRight Home End
Focus
managed
  • The category strip is a real tablist with each tab pointing at the grid it fills through aria-controls, and every interactive control uses @mousedown.prevent to preserve the editor selection.
  • Semantic HTML and a stable data-slot attribute for styling and scripting hooks.
  • Focus-visible rings use the ring token, 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 under dir="rtl" — flip the preview to RTL to confirm.
  • Dark mode uses the same semantic tokens under the dark class; high contrast follows forced-color system tokens.

Livewire

Needs wire:key

Add a stable wire:key when Livewire can reorder this interactive component.

livewire-component.blade.php Blade
<div wire:key="emoji-picker-{{ $record->id }}">
    <div class="w-full flex justify-center p-6">
        <x-ui.emoji-picker />
    </div>
</div>

Validation

Validation support: native. Keep the error message connected with aria-describedby.

livewire-form.blade.php Blade
<form wire:submit="save" class="space-y-2">
    <brok:emoji-picker
        wire:model="value"
        :aria-invalid="$errors->has('value') ? 'true' : 'false'"
        aria-describedby="value-error"
    />

    @error('value')
        <p id="value-error" role="alert">{{ $message }}</p>
    @enderror

    <brok:button type="submit" wire:loading.attr="disabled">
        <span wire:loading.remove>Save</span>
        <span wire:loading>Saving…</span>
    </brok:button>
</form>

Source

The exact, editable files ui:add writes into your app. Previews render this same code; there are no preview-only components.

resources/views/components/ui/emoji-picker.blade.php Blade
@props([
    'placeholder' => 'Search emoji…',
])

@php
    $uid = 'emoji-picker-'.\Illuminate\Support\Str::random(8);
@endphp

{{--
    Emoji Picker: a popover-style grid of plain Unicode emoji characters with
    category tabs and a search input.

    Editor contract
    ───────────────
    When a descendant of <x-ui.editor>, choosing an emoji dispatches two events:
      • 'editor-insert' { html: emoji }  — caught by x-ui.editor's x-on:editor-insert
        to insert the character at the current caret position.
      • 'emoji-select'  { emoji }        — for standalone / custom listeners.

    All interactive controls use @mousedown.prevent before @click so clicking the
    picker never blurs an active editor or drops the current text selection.

    Keyboard
    ────────
    The category strip is a real tablist: one tab stop, Arrow Left/Right and
    Home/End walk the categories, and every tab points at the grid it fills. The
    grid is a second single tab stop — Arrow keys walk it in two dimensions
    (eight per row), Home/End jump to the ends — so Tab moves past the picker
    instead of through a hundred emoji.
--}}
<div
    data-slot="emoji-picker"
    x-data="uiEmojiPicker()"
    {{ $attributes->merge(['class' => 'w-72 rounded-lg border border-border bg-popover text-popover-foreground shadow-md']) }}
>
    {{-- Search --}}
    <div class="border-b border-border px-3 py-2">
        <x-ui.input
            x-model="query"
            @input="activeIndex = 0"
            type="search"
            placeholder="{{ __($placeholder) }}"
            aria-label="{{ __('Search emoji') }}"
            class="h-8 text-sm"
        />
    </div>

    {{-- Category tabs --}}
    <div
        role="tablist"
        x-ref="tablist"
        aria-label="{{ __('Emoji categories') }}"
        @keydown="onTabKey($event)"
        class="flex items-center gap-0.5 overflow-x-auto border-b border-border px-2 py-2 scrollbar-none"
    >
        {{-- Smileys --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-smileys"
            data-category="smileys"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'smileys'"
            :tabindex="category === 'smileys' ? 0 : -1"
            :class="category === 'smileys' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('smileys')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
            title="{{ __('Smileys & Emotions') }}"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M8 14s1.5 2 4 2 4-2 4-2"/><line x1="9" y1="9" x2="9.01" y2="9"/><line x1="15" y1="9" x2="15.01" y2="9"/></svg>
            <span class="sr-only">{{ __('Smileys & Emotions') }}</span>
        </button>

        {{-- Gestures --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-gestures"
            data-category="gestures"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'gestures'"
            :tabindex="category === 'gestures' ? 0 : -1"
            :class="category === 'gestures' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('gestures')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 11V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2"/><path d="M14 10V4a2 2 0 0 0-2-2 2 2 0 0 0-2 2v2"/><path d="M10 10.5V6a2 2 0 0 0-2-2 2 2 0 0 0-2 2v8"/><path d="M18 11a2 2 0 1 1 4 0v3a8 8 0 0 1-8 8h-4a8 8 0 0 1-8-8 2 2 0 1 1 4 0"/></svg>
            <span class="sr-only">{{ __('People & Gestures') }}</span>
        </button>

        {{-- Nature --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-nature"
            data-category="nature"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'nature'"
            :tabindex="category === 'nature' ? 0 : -1"
            :class="category === 'nature' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('nature')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 20A7 7 0 0 1 9.8 6.1C15.5 5 17 4.48 19 2c1 2 2 4.18 2 8 0 5.5-4.78 10-10 10Z"/><path d="M2 21c0-3 1.85-5.36 5.08-6C9.5 14.52 12 13 13 12"/></svg>
            <span class="sr-only">{{ __('Nature & Animals') }}</span>
        </button>

        {{-- Food --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-food"
            data-category="food"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'food'"
            :tabindex="category === 'food' ? 0 : -1"
            :class="category === 'food' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('food')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2a10 10 0 1 0 10 10"/><path d="M12 8v4l3 3"/></svg>
            <span class="sr-only">{{ __('Food & Drink') }}</span>
        </button>

        {{-- Activity --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-activity"
            data-category="activity"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'activity'"
            :tabindex="category === 'activity' ? 0 : -1"
            :class="category === 'activity' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('activity')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M4.93 4.93 19.07 19.07"/></svg>
            <span class="sr-only">{{ __('Activities & Sports') }}</span>
        </button>

        {{-- Objects --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-objects"
            data-category="objects"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'objects'"
            :tabindex="category === 'objects' ? 0 : -1"
            :class="category === 'objects' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('objects')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 14c.2-1 .7-1.7 1.5-2.5 1-.9 1.5-2.2 1.5-3.5A6 6 0 0 0 6 8c0 1 .2 2.2 1.5 3.5.7.7 1.3 1.5 1.5 2.5"/><path d="M9 18h6"/><path d="M10 22h4"/></svg>
            <span class="sr-only">{{ __('Objects & Tools') }}</span>
        </button>

        {{-- Symbols --}}
        <button
            type="button"
            role="tab"
            id="{{ $uid }}-tab-symbols"
            data-category="symbols"
            aria-controls="{{ $uid }}-grid"
            :aria-selected="category === 'symbols'"
            :tabindex="category === 'symbols' ? 0 : -1"
            :class="category === 'symbols' ? 'bg-accent text-foreground' : 'text-muted-foreground hover:bg-accent/50 hover:text-foreground'"
            @mousedown.prevent
            @click="selectCategory('symbols')"
            class="flex h-7 w-7 shrink-0 items-center justify-center rounded transition-colors"
        >
            <svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"/></svg>
            <span class="sr-only">{{ __('Symbols & Signs') }}</span>
        </button>
    </div>

    {{-- Emoji grid --}}
    <div
        id="{{ $uid }}-grid"
        role="tabpanel"
        x-ref="grid"
        aria-label="{{ __('Emoji list') }}"
        @keydown="onGridKey($event)"
        class="grid grid-cols-8 gap-0.5 overflow-y-auto p-2"
        style="max-height: 14rem;"
    >
        <template x-if="filtered().length === 0">
            <p class="col-span-8 py-4 text-center text-sm text-muted-foreground">{{ __('No emoji found') }}</p>
        </template>
        <template x-for="(emoji, index) in filtered()" :key="index">
            <button
                type="button"
                :aria-label="emoji"
                :data-index="index"
                :tabindex="index === activeIndex ? 0 : -1"
                @focus="activeIndex = index"
                @mousedown.prevent
                @click="choose(emoji)"
                class="flex h-8 w-8 items-center justify-center rounded text-base transition-colors hover:bg-accent focus:bg-accent focus:outline-none focus-visible:ring-2 focus-visible:ring-ring"
                x-text="emoji"
            ></button>
        </template>
    </div>

    {{-- Footer / preview --}}
    <div class="flex items-center gap-2 border-t border-border px-3 py-2">
        <span
            class="text-lg leading-none"
            x-text="filtered()[0] ?? ''"
            aria-hidden="true"
        ></span>
        <span class="text-sm text-muted-foreground" x-text="filtered().length > 0 ? filtered().length + ' {{ __('emoji') }}' : '{{ __('No results') }}'"></span>
    </div>
</div>
resources/js/ui/emoji-picker.js JS
/**
 * Emoji Picker behavior (uiEmojiPicker).
 *
 * Renders a categorised grid of plain Unicode emoji characters with a search
 * input and category tab bar. Integrates with the editor contract:
 *
 *   choose(emoji)   dispatches 'editor-insert' { text: emoji, html } (html is
 *                   the escaped emoji for older hosts; bubbles so an
 *                   ancestor <x-ui.editor> can catch it) AND 'emoji-select'
 *                   { emoji } for standalone usage.
 *
 * Controls use @mousedown.prevent before @click so clicking the picker never
 * blurs an active editor / drops the current text selection.
 *
 * Keyboard: the category strip is a tablist with a roving tabindex (Arrow
 * Left/Right, Home/End) and the emoji grid is a second roving tab stop walked
 * in two dimensions (Arrow keys, eight per row, Home/End). Both are single tab
 * stops, so Tab moves past the picker rather than through a hundred emoji.
 *
 * Teardown: no global listeners are added, so destroy() is a no-op.
 *
 * Self-registers on `alpine:init` so import order does not matter — import this
 * file once from your bundle (e.g. resources/js/ui/index.js).
 */
document.addEventListener('alpine:init', () => {
    window.Alpine.data('uiEmojiPicker', () => ({
        query: '',
        category: 'smileys',
        /** Roving tab stop inside the emoji grid. */
        activeIndex: 0,
        /** Grid template column count — keep in step with `grid-cols-8`. */
        columns: 8,

        /** Built-in emoji data — plain Unicode characters, no images. */
        data: {
            smileys: [
                '😀','😁','😂','🤣','😃','😄','😅','😆','😇','😈','😉','😊',
                '😋','😌','😍','🥰','😎','😏','😐','😑','😒','😓','😔','😕',
                '😖','😗','😘','😙','😚','😛','😜','😝','😞','😟','😠','😡',
                '😢','😣','😤','😥','😦','😧','😨','😩','😪','😫','🥱','😬',
                '😭','😮','😯','😰','😱','😲','😳','🥴','😴','😵','🤐','🥺',
                '😶','😷','🤒','🤓','🤔','🤕','🤗','🤧','🤨','🤩','🤪','🤫',
                '🤬','🤭','🤮','🤯','🥵','🥶','🥳','🤠','😸','😹','😺','😻',
                '😼','😽','🙀','😿','😾','🙈','🙉','🙊',
            ],
            gestures: [
                '👍','👎','👏','🙌','👋','🤚','🖐','✋','🖖','🤙','💪','🦵',
                '🦶','👊','✊','🤛','🤜','🤞','🤟','🤘','👌','🤌','🤏','✌',
                '🖕','👈','👉','👆','👇','☝','👍','🤜','🤝','🙏','👐','🤲',
                '🤳','💅','🖐','✋','💋','👄','👅','👁','👀','🫀','🫁','🧠',
            ],
            nature: [
                '🌱','🌿','🍀','🌸','🌺','🌻','🌹','🌷','🌳','🌲','🌴','🌵',
                '🎋','🎍','🍃','🍂','🍁','🍄','🌾','🌞','🌝','🌛','🌜','🌙',
                '⭐','🌟','💫','✨','☀️','🌤','⛅','🌦','🌧','🌨','🌩','🌪',
                '🌫','🌬','🌀','🌈','☁','🌊','🌋','🏔','🏕','🐶','🐱','🐭',
                '🐹','🐰','🦊','🐻','🐼','🐨','🐯','🦁','🐮','🐷','🐸','🐙',
            ],
            food: [
                '🍎','🍊','🍋','🍇','🍓','🫐','🍈','🍒','🍑','🥭','🍍','🥥',
                '🥝','🍅','🥑','🍆','🥦','🥬','🌽','🌶','🧄','🧅','🍔','🍟',
                '🌭','🍕','🫔','🌮','🌯','🥙','🧆','🥚','🍳','🥘','🍲','🥣',
                '🥗','🍿','🧂','🍱','🍣','🍤','🍙','🍚','🍛','🍜','🍝','🍠',
                '🍢','🍡','🍧','🍨','🍦','🥧','🍰','🎂','🍮','🍭','🍬','🍫',
                '🍩','🍪','☕','🍵','🧃','🥤','🧋','🍺','🍷','🥂','🍸','🍹',
            ],
            activity: [
                '⚽','🏀','🏈','⚾','🎾','🏐','🏉','🎱','🏓','🏸','🥅','⛳',
                '🏹','🎣','🥊','🥋','🎽','⛸','🛷','🎿','🏂','🏋','🤸','🤼',
                '🤺','🏇','⛷','🏊','🏄','🧗','🚵','🚴','🏆','🥇','🥈','🥉',
                '🏅','🎖','🏵','🎗','🎫','🎟','🎪','🤹','🎭','🎨','🎬','🎤',
                '🎧','🎼','🎵','🎶','🎹','🎸','🎷','🎺','🎻','🪗','🥁','🎲',
                '♟','🎯','🎳','🎰','🎮','🕹','🎉','🎊','🎋','🎍','🎈','🎀',
            ],
            objects: [
                '💡','🔦','🕯','🪔','💰','💳','💎','⚖','🔑','🗝','🔒','🔓',
                '🔨','⛏','🔧','🔩','🪛','🗜','⚙','🖇','📌','📍','✂','🧰',
                '🪝','🧲','🪜','🧪','🧫','🧬','🔭','🔬','🩺','🩹','💊','🩻',
                '🪄','🔮','🧿','🪬','💈','⚗','🔱','📎','✏','📝','📖','📚',
                '📅','📆','📇','🗒','🗓','📐','📏','🖊','🖋','✒','📋','📁',
                '🖨','💻','🖥','🖱','🖲','💾','💿','📱','📞','☎','📺','📷',
            ],
            symbols: [
                '✅','❌','❓','❗','💯','🔴','🟠','🟡','🟢','🔵','🟣','⚫',
                '⚪','🔶','🔷','🔸','🔹','🔺','🔻','💠','🔘','🔲','🔳','⬛',
                '⬜','◼','◻','◾','◽','▪','▫','⭐','❤','🧡','💛','💚',
                '💙','💜','🖤','🤍','🤎','💔','❣','💕','💞','💓','💗','💖',
                '💘','💝','🔥','💥','✨','🌟','💫','💢','💤','💬','💭','🗯',
                '♻','⚜','🔰','♾','✴','🆕','🆙','🆒','🆓','🆖','🆗','🆘',
            ],
        },

        /** Category metadata (order, label, icon). */
        categories: [
            { key: 'smileys',  label: 'Smileys & Emotions' },
            { key: 'gestures', label: 'People & Gestures' },
            { key: 'nature',   label: 'Nature & Animals' },
            { key: 'food',     label: 'Food & Drink' },
            { key: 'activity', label: 'Activities & Sports' },
            { key: 'objects',  label: 'Objects & Tools' },
            { key: 'symbols',  label: 'Symbols & Signs' },
        ],

        init() {},

        destroy() {},

        /**
         * Returns the emoji list to display.
         * If `query` is non-empty, search across all categories (simple includes
         * match against the Unicode character itself — sufficient for a type-to-find
         * flow where users type the emoji or a letter they can visually match).
         * Otherwise, return the active category list.
         */
        filtered() {
            if (this.query.trim() !== '') {
                const q = this.query.trim().toLowerCase();
                const all = Object.values(this.data).flat();
                // Keep any emoji whose code-point description or the query itself
                // roughly matches — for Unicode chars a simple visual search
                // (typing the emoji directly) works; letter queries surface emojis
                // that contain the query character in their representation.
                return all.filter((e) => e.includes(q) || this._nameHint(e).includes(q));
            }
            return this.data[this.category] ?? [];
        },

        /**
         * Minimal keyword hint for search — avoids a full name lookup table while
         * still letting common words ('heart', 'fire', 'star', 'check', 'cross',
         * 'pizza', 'coffee', 'soccer', 'music') surface relevant emojis.
         */
        _nameHint(emoji) {
            const hints = {
                '❤': 'heart love', '🔥': 'fire hot', '⭐': 'star',
                '✅': 'check yes done', '❌': 'cross no', '🎵': 'music note',
                '🎸': 'guitar music', '🍕': 'pizza food', '☕': 'coffee drink',
                '⚽': 'soccer football', '🏀': 'basketball', '🎮': 'game gaming',
                '💡': 'idea light bulb', '🔒': 'lock secure', '🔑': 'key',
                '🌱': 'plant nature grow', '🌸': 'flower blossom', '🌊': 'wave ocean',
                '🌙': 'moon night', '☀️': 'sun day', '🌈': 'rainbow colors',
                '👍': 'thumbs up like good', '👎': 'thumbs down dislike',
                '👏': 'clap applause', '🤝': 'handshake deal', '🙏': 'pray thanks',
                '😀': 'happy smile grin', '😂': 'laugh funny lol', '😍': 'love heart eyes',
                '😢': 'sad cry tears', '😡': 'angry mad', '😎': 'cool sunglasses',
                '🤔': 'thinking hmm', '🥺': 'pleading eyes cute',
            };
            return hints[emoji] ?? '';
        },

        /** Switch category, clear the search and park the grid on its first cell. */
        selectCategory(key) {
            this.category = key;
            this.query = '';
            this.activeIndex = 0;
        },

        focusTab(key) {
            this.$refs.tablist?.querySelector(`[data-category="${key}"]`)?.focus();
        },

        /** Tablist keyboard model: the arrows move and select in one step. */
        onTabKey(event) {
            const keys = this.categories.map((entry) => entry.key);
            const index = keys.indexOf(this.category);
            let next = null;

            if (event.key === 'ArrowRight') next = (index + 1) % keys.length;
            else if (event.key === 'ArrowLeft') next = (index - 1 + keys.length) % keys.length;
            else if (event.key === 'Home') next = 0;
            else if (event.key === 'End') next = keys.length - 1;

            if (next === null) {
                return;
            }

            event.preventDefault();
            this.selectCategory(keys[next]);
            this.$nextTick(() => this.focusTab(keys[next]));
        },

        focusCell(index) {
            const count = this.filtered().length;
            if (count === 0) {
                return;
            }
            this.activeIndex = Math.min(Math.max(index, 0), count - 1);
            this.$nextTick(() => {
                this.$refs.grid
                    ?.querySelector(`[data-index="${this.activeIndex}"]`)
                    ?.focus();
            });
        },

        /** Grid keyboard model: two-dimensional, clamped at the edges. */
        onGridKey(event) {
            const deltas = {
                ArrowRight: 1,
                ArrowLeft: -1,
                ArrowDown: this.columns,
                ArrowUp: -this.columns,
            };

            if (event.key in deltas) {
                event.preventDefault();
                this.focusCell(this.activeIndex + deltas[event.key]);

                return;
            }

            if (event.key === 'Home') {
                event.preventDefault();
                this.focusCell(0);
            } else if (event.key === 'End') {
                event.preventDefault();
                this.focusCell(this.filtered().length - 1);
            }
        },

        /** Handle emoji selection — notify editor and standalone listeners. */
        // `text` is inserted literally by the editor; `html` stays for older
        // hosts and is escaped, so a caller-supplied string is never markup.
        choose(emoji) {
            const html = String(emoji).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
            this.$dispatch('editor-insert', { html, text: emoji });
            this.$dispatch('emoji-select', { emoji });
        },
    }));
});

Ownership & lifecycle

Owner, release state, review evidence and adoption for this item.
Owner
Platform UI (@JoshJML)
Current version
1.2.3
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