mitai-jinkendo/frontend/src/components/EmojiIconPicker.jsx
Lars a7058c30be
All checks were successful
Deploy Development / deploy (push) Successful in 49s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 14s
feat: Enhance EmojiIconPicker with search functionality and keyword support
2026-04-04 14:22:44 +02:00

423 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState, useId, useMemo, useEffect } from 'react'
import { ChevronDown, ChevronUp } from 'lucide-react'
import { haystackForEmoji, matchesEmojiSearch } from './emojiIconPickerKeywords.js'
/**
* Kuratierte Emoji-Gruppen (viele Einzel-Glyphen für breite OS-Unterstützung).
* Erste Sport-Gruppe: typische Vereins-/Breitensportarten in Deutschland (DOSB/Nischensport mit abgedeckt).
* Erweiterbar: prop `extraGroups` an EmojiIconPicker, oder diese Konstante editieren.
*/
export const EMOJI_ICON_GROUPS = [
{
label: 'Sportarten (typisch Deutschland)',
emojis: [
// Vereins- & Breitensport (Reihenfolge: Fußball, Handball, Kampfsport Gi = Karate/Judo/…, …)
'⚽',
'🤾',
'🤾‍♂️',
'🤾‍♀️',
'🥋',
'🏐',
'🏀',
'🎾',
'🏓',
'🏸',
'🏒',
'🏑',
'🥍',
'🏈',
'🏉',
'⚾',
'🥎',
'⛹️',
'⛹️‍♂️',
'⛹️‍♀️',
'🥅',
'⛷️',
'🎿',
'🏂',
'🛷',
'⛸️',
'🥌',
'🚴',
'🚴‍♂️',
'🚴‍♀️',
'🚵',
'🚵‍♂️',
'🚵‍♀️',
'🏃',
'🏃‍♂️',
'🏃‍♀️',
'🏃‍➡️',
'🚶',
'🥾',
'🏊',
'🏊‍♂️',
'🏊‍♀️',
'🤽',
'🤽‍♂️',
'🤽‍♀️',
'🤿',
'🏄',
'🏄‍♂️',
'🏄‍♀️',
'🚣',
'🚣‍♂️',
'🚣‍♀️',
'⛵',
'🧗',
'🧗‍♂️',
'🧗‍♀️',
'🏋️',
'🏋️‍♂️',
'🏋️‍♀️',
'🤸',
'🤸‍♂️',
'🤸‍♀️',
'🥊',
'🤼',
'🤺',
'🏇',
'⛳',
'🏌️',
'🏌️‍♂️',
'🏌️‍♀️',
'💃',
'🕺',
'🧘',
'🧘‍♂️',
'🧘‍♀️',
'🛼',
'🛹',
'🎯',
'🎳',
'🏟️',
'🏆',
'🥇',
'🥈',
'🥉'
]
},
{
label: 'Weitere Sportarten & Hobbysport',
emojis: [
'🎱',
'🎣',
'🤹',
'🪁',
'🥏',
'🛶',
'🏹',
'🌊',
'🏖️',
'🛣️',
'🧭',
'🏕️',
'⛺'
]
},
{
label: 'Yoga, Geist, Balance',
emojis: [
'🧘', '🧘‍♂️', '🧘‍♀️', '🪷', '☯️', '🕉️', '🙏', '🧎', '🧍', '💭', '📿', '🎼',
'🎹', '🥁', '🎸', '🎺', '🔔', '✨', '🌟', '💫', '🔮'
]
},
{
label: 'Outdoor & Natur',
emojis: [
'⛰️', '🏔️', '🗻', '🌋', '🏕️', '⛺', '🧭', '🗺️', '🌲', '🌳', '🌴', '🍃',
'🍂', '🌿', '☘️', '🪨', '🏞️', '🏜️', '🏖️', '🌅', '🌄', '🌈', '⛅', '🌤️',
'☀️', '🌙', '⭐', '🌠', '❄️', '☃️', '⛄'
]
},
{
label: 'Körper & Medizin',
emojis: [
'💪', '🦾', '🦵', '🦶', '🖐️', '✋', '👣', '❤️', '🩷', '💙', '💚', '🫀',
'🫁', '🧠', '👁️', '👂', '🦷', '🦴', '🧬', '⚕️', '🩺', '🩹', '🩼', '💊',
'🌡️', '🔬', '🧪', '🧫', '♿', '⚖️', '📏', '📐'
]
},
{
label: 'Ernährung & Getränke',
emojis: [
'🍎', '🍐', '🍊', '🍋', '🍌', '🍉', '🍇', '🍓', '🫐', '🍒', '🍑', '🥭',
'🍍', '🥝', '🍅', '🥑', '🥦', '🥬', '🥒', '🌶️', '🫑', '🌽', '🥕', '🫒',
'🧄', '🧅', '🥔', '🍠', '🥐', '🍞', '🥖', '🥨', '🧀', '🥚', '🍳', '🧈',
'🥞', '🧇', '🥓', '🥩', '🍗', '🍖', '🌭', '🍔', '🍟', '🍕', '🫓', '🥙',
'🌮', '🌯', '🥗', '🍝', '🍜', '🍲', '🍛', '🍣', '🍱', '🥟', '🦪', '🍤',
'🍙', '🍚', '🍘', '🍥', '🥠', '🥮', '🍢', '🍡', '🍧', '🍨', '🍦', '🥧',
'🧁', '🍰', '🎂', '🍮', '🍭', '🍬', '🍫', '🍿', '🍩', '🍪', '🌰', '🥜',
'🍯', '🥛', '🍼', '🫖', '☕', '🍵', '🧃', '🥤', '🧋', '🍶', '🍺', '🍻',
'🥂', '🍷', '🥃', '🍸', '🍹', '🧉', '🍾', '💧', '🧊'
]
},
{
label: 'Schlaf & Erholung',
emojis: [
'😴', '🛌', '🛏️', '💤', '🌙', '🌛', '🌜', '💆', '💆‍♂️', '💆‍♀️', '🧖', '🧖‍♂️',
'🧖‍♀️', '🧴', '🛁', '🚿', '🪥', '🩴', '🧘', '🕯️'
]
},
{
label: 'Stimmung & Motivation Smileys',
emojis: [
'😊', '🙂', '😌', '😎', '🤩', '🥳', '😤', '💯', '🙌', '👏', '🤝', '👍',
'👎', '✊', '🤛', '🤜', '💪', '🦵', '🧗', '🔥', '💥', '⚡', '🎉', '🏆',
'🥇', '🥈', '🥉', '🎖️', '🏅', '😅', '🤔', '🧐', '😇'
]
},
{
label: 'Tiere (Maskottchen)',
emojis: [
'🐶', '🐱', '🐭', '🐹', '🐰', '🦊', '🐻', '🐼', '🐨', '🐯', '🦁', '🐮',
'🐷', '🐸', '🐵', '🐔', '🐧', '🐦', '🐤', '🦆', '🦅', '🦉', '🦇', '🐺',
'🐗', '🐴', '🦄', '🐝', '🐛', '🦋', '🐌', '🐞', '🐜', '🦗', '🕷️', '🦂',
'🐢', '🐍', '🦎', '🦖', '🦕', '🐙', '🦑', '🦐', '🦞', '🐠', '🐟', '🐬',
'🐳', '🐋', '🦈', '🐊'
]
},
{
label: 'Symbole & Pointers',
emojis: [
'🎯', '📊', '📈', '📉', '🧮', '📋', '📌', '📍', '🔖', '🏷️', '✏️', '✒️',
'🖊️', '📎', '🔗', '⛓️', '🔒', '🔓', '🔑', '🗝️', '🔨', '🛠️', '⚙️', '🧰',
'💡', '🔦', '🏮', '🪔', '📣', '📢', '🔔', '🔕', '⏱️', '⏰', '🕐', '📅',
'🗓️', '✅', '☑️', '✔️', '❌', '⭕', '❗', '❓', '💬', '🗨️', '📝', '📖',
'🪄', '🎪', '🎭', '🎬', '🎨', '🖼️', '🧩', '♟️', '🎲', '🧸'
]
},
{
label: 'Fahrzeuge & Weg',
emojis: [
'🚗', '🚕', '🚙', '🚌', '🚎', '🏎️', '🚓', '🚑', '🚒', '🚐', '🛻', '🚚',
'🚛', '🚜', '🛵', '🏍️', '🛺', '🚲', '🛴', '🛹', '🚁', '✈️', '🛫', '🛬',
'🪂', '🚀', '🛶', '⛵', '🚤', '🛥️', '🛳️', '⛴️', '🚢', '⚓', '🗼', '🏟️'
]
}
]
/**
* Wiederverwendbare Emoji-/Icon-Auswahl: Vorschau, Freitext (inkl. System-Emoji-Picker),
* optional ausklappbare Vorschläge.
*
* @param {string} value aktueller Icon-String (meist ein Emoji)
* @param {(next: string) => void} onChange
* @param {string} [placeholder]
* @param {number} [maxLength=10]
* @param {boolean} [disabled]
* @param {string} [id] optionale ID für das Textfeld (Label for=)
* @param {boolean} [defaultExpanded=false] Vorschlags-Bereich initial offen
* @param {{ label: string, emojis: string[] }[]} [extraGroups=[]] eigene Gruppe(n) anhängen (z. B. Projekt-Favoriten)
*/
export default function EmojiIconPicker({
value,
onChange,
placeholder = '📝',
maxLength = 10,
disabled = false,
id: idProp,
defaultExpanded = false,
extraGroups = []
}) {
const uid = useId()
const inputId = idProp || `emoji-icon-${uid}`
const searchInputId = `${inputId}-picker-search`
const [open, setOpen] = useState(defaultExpanded)
const [pickerSearch, setPickerSearch] = useState('')
const groups =
extraGroups.length > 0 ? [...EMOJI_ICON_GROUPS, ...extraGroups] : EMOJI_ICON_GROUPS
useEffect(() => {
if (!open) setPickerSearch('')
}, [open])
const filteredGroups = useMemo(() => {
const q = pickerSearch
if (!q.trim()) {
return groups
}
return groups
.map((g) => ({
...g,
emojis: g.emojis.filter((em) => matchesEmojiSearch(haystackForEmoji(em, g.label), q))
}))
.filter((g) => g.emojis.length > 0)
}, [groups, pickerSearch])
const handleInput = (e) => {
onChange(e.target.value.slice(0, maxLength))
}
const pick = (em) => {
onChange(em.slice(0, maxLength))
}
return (
<div className="emoji-icon-picker" style={{ width: '100%' }}>
<div
style={{
display: 'flex',
gap: 8,
alignItems: 'center',
flexWrap: 'wrap'
}}
>
<span
style={{
fontSize: 28,
lineHeight: 1,
minWidth: 40,
textAlign: 'center',
opacity: value ? 1 : 0.35
}}
aria-hidden
>
{value || '·'}
</span>
<input
id={inputId}
type="text"
className="form-input"
style={{ flex: 1, minWidth: 120 }}
value={value}
onChange={handleInput}
placeholder={placeholder}
maxLength={maxLength}
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="text"
/>
<button
type="button"
className="btn btn-secondary"
disabled={disabled}
onClick={() => setOpen((o) => !o)}
style={{
display: 'inline-flex',
alignItems: 'center',
gap: 4,
padding: '8px 12px',
fontSize: 13
}}
aria-expanded={open}
>
{open ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
Vorschläge
</button>
{!!value && !disabled && (
<button
type="button"
className="btn btn-secondary"
onClick={() => onChange('')}
style={{ padding: '8px 12px', fontSize: 13 }}
>
Leeren
</button>
)}
</div>
{open && (
<div
role="listbox"
aria-label="Emoji-Vorschläge"
style={{
marginTop: 10,
padding: 12,
background: 'var(--surface2)',
borderRadius: 12,
border: '1px solid var(--border)',
maxHeight: 'min(72vh, 460px)',
overflowY: 'auto'
}}
>
<label htmlFor={searchInputId} className="form-label" style={{ marginBottom: 6 }}>
In Vorschlägen suchen
</label>
<input
id={searchInputId}
type="search"
className="form-input"
value={pickerSearch}
onChange={(e) => setPickerSearch(e.target.value)}
placeholder="z. B. rollschuh, karate, apfel…"
disabled={disabled}
autoComplete="off"
spellCheck={false}
inputMode="search"
style={{ width: '100%', marginBottom: 12, boxSizing: 'border-box' }}
/>
{filteredGroups.length === 0 && pickerSearch.trim() && (
<p
style={{
fontSize: 13,
color: 'var(--text2)',
margin: '0 0 12px 0',
lineHeight: 1.45
}}
>
Keine Treffer für {pickerSearch.trim()}. Andere Begriffe probieren oder oben ein Emoji
einfügen (z.&nbsp;B. Win&nbsp;+&nbsp;.).
</p>
)}
{filteredGroups.map((group, gi) => (
<div key={`${group.label}-${gi}`} style={{ marginBottom: 14 }}>
<div
style={{
fontSize: 11,
fontWeight: 600,
color: 'var(--text3)',
marginBottom: 8,
textTransform: 'uppercase',
letterSpacing: '0.04em'
}}
>
{group.label}
</div>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: 6
}}
>
{group.emojis.map((em, ei) => (
<button
key={`${group.label}-${gi}-${ei}-${em}`}
type="button"
onClick={() => pick(em)}
disabled={disabled}
title={em}
aria-label={`Icon wählen: ${em}`}
style={{
fontSize: 22,
lineHeight: 1,
padding: '8px 10px',
border: `1px solid ${value === em ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 8,
background:
value === em ? 'var(--accent-light)' : 'var(--surface)',
cursor: disabled ? 'not-allowed' : 'pointer',
opacity: disabled ? 0.5 : 1
}}
>
{em}
</button>
))}
</div>
</div>
))}
<p style={{ fontSize: 11, color: 'var(--text3)', margin: '12px 0 0 0', lineHeight: 1.5 }}>
Du kannst auch direkt in das Feld tippen oder das Betriebssystem-Emoji-Menü nutzen
(z.&nbsp;B. Win + . unter Windows). Mehrere Wörter verfeinern die Suche: jedes muss in den
Stichwörtern vorkommen.{' '}
<strong>Inline Skating:</strong> Es gibt kein separates Inliner-Emoji; 🛼 (Rollschuh) wird dafür oft genutzt Suchbegriffe: rollschuh, inline, inliner.{' '}
<strong>Karate / Kampfsport mit Gi:</strong> 🥋 (gemeinsames Unicode-Symbol für u. a. Karate, Judo, Ju-Jitsu).
</p>
</div>
)}
</div>
)
}