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).
Preview
No emoji found
<div class="w-full flex justify-center p-6">
<x-ui.emoji-picker />
</div>
Installation
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:
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.
-
resources/views/components/ui/emoji-picker.blade.php -
resources/js/ui/emoji-picker.js
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.
# 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
Props
| 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.
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
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
- Theming hooks
Accessibility
- 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-slotattribute for styling and scripting hooks. - Focus-visible rings use the
ringtoken, 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 underdir="rtl"— flip the preview to RTL to confirm. - Dark mode uses the same semantic tokens under the
darkclass; high contrast follows forced-color system tokens.
Livewire
Add a stable wire:key when Livewire can reorder this interactive component.
<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.
<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.
@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>
/**
* 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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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