mitai-jinkendo/frontend/src/components/EmojiIconPicker.jsx

423 lines
13 KiB
React
Raw Normal View History

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>
)
}