mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
refine places sidebar: filter counts, compact select UI, tooltip component
- replace "Auswählen" button with small Check↔X toggle next to category dropdown - move bulk-action bar below search, icon-only buttons (Select all, Delete) - filter tabs as pill buttons with per-filter count badges - shared Tooltip component (portaled, delayed) replaces native title - apply tooltip to select toggle, bulk actions, add note, add transport - rename places.importFile: "Datei importieren" -> "Dateimport"
This commit is contained in:
@@ -23,6 +23,7 @@ import { useSettingsStore } from '../../store/settingsStore'
|
|||||||
import { useTranslation } from '../../i18n'
|
import { useTranslation } from '../../i18n'
|
||||||
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
import { formatDate, formatTime, dayTotalCost, currencyDecimals } from '../../utils/formatters'
|
||||||
import { useDayNotes } from '../../hooks/useDayNotes'
|
import { useDayNotes } from '../../hooks/useDayNotes'
|
||||||
|
import Tooltip from '../shared/Tooltip'
|
||||||
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
import type { Trip, Day, Place, Category, Assignment, Reservation, AssignmentsMap, RouteResult } from '../../types'
|
||||||
|
|
||||||
const NOTE_ICONS = [
|
const NOTE_ICONS = [
|
||||||
@@ -1143,9 +1144,10 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Pencil size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>}
|
</button>}
|
||||||
{canEditDays && onAddTransport && (
|
{canEditDays && onAddTransport && (
|
||||||
|
<Tooltip label={t('transport.addTransport')} placement="top">
|
||||||
<button
|
<button
|
||||||
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
onClick={e => { e.stopPropagation(); onAddTransport(day.id) }}
|
||||||
title={t('transport.addTransport')}
|
aria-label={t('transport.addTransport')}
|
||||||
style={{
|
style={{
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
background: 'none',
|
background: 'none',
|
||||||
@@ -1162,6 +1164,7 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
>
|
>
|
||||||
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
<Plus size={15} strokeWidth={1.8} color="var(--text-secondary)" />
|
||||||
</button>
|
</button>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
{(() => {
|
{(() => {
|
||||||
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
const dayAccs = accommodations.filter(a => day.id >= a.start_day_id && day.id <= a.end_day_id)
|
||||||
@@ -1217,15 +1220,15 @@ const DayPlanSidebar = React.memo(function DayPlanSidebar({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{canEditDays && <button
|
{canEditDays && <Tooltip label={t('dayplan.addNote')} placement="top"><button
|
||||||
onClick={e => openAddNote(day.id, e)}
|
onClick={e => openAddNote(day.id, e)}
|
||||||
title={t('dayplan.addNote')}
|
aria-label={t('dayplan.addNote')}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
onMouseEnter={e => e.currentTarget.style.color = 'var(--text-primary)'}
|
||||||
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
onMouseLeave={e => e.currentTarget.style.color = 'var(--text-faint)'}
|
||||||
>
|
>
|
||||||
<FileText size={16} strokeWidth={2} />
|
<FileText size={16} strokeWidth={2} />
|
||||||
</button>}
|
</button></Tooltip>}
|
||||||
<button
|
<button
|
||||||
onClick={e => toggleDay(day.id, e)}
|
onClick={e => toggleDay(day.id, e)}
|
||||||
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
style={{ flexShrink: 0, background: 'none', border: 'none', padding: 6, cursor: 'pointer', display: 'flex', alignItems: 'center', color: 'var(--text-faint)' }}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { useCanDo } from '../../store/permissionsStore'
|
|||||||
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
import type { Place, Category, Day, AssignmentsMap } from '../../types'
|
||||||
import FileImportModal from './FileImportModal'
|
import FileImportModal from './FileImportModal'
|
||||||
import ConfirmDialog from '../shared/ConfirmDialog'
|
import ConfirmDialog from '../shared/ConfirmDialog'
|
||||||
|
import Tooltip from '../shared/Tooltip'
|
||||||
|
|
||||||
interface PlacesSidebarProps {
|
interface PlacesSidebarProps {
|
||||||
tripId: number
|
tripId: number
|
||||||
@@ -372,74 +373,65 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
>
|
>
|
||||||
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
<MapPin size={11} strokeWidth={2} /> {t(hasMultipleListImportProviders ? 'places.importList' : 'places.importGoogleList')}
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 5,
|
|
||||||
padding: '5px 10px', borderRadius: 8,
|
|
||||||
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
|
||||||
background: selectMode ? 'color-mix(in srgb, var(--accent) 12%, transparent)' : 'none',
|
|
||||||
color: selectMode ? 'var(--accent)' : 'var(--text-faint)', fontSize: 11, fontWeight: 500,
|
|
||||||
cursor: 'pointer', fontFamily: 'inherit', flexShrink: 0,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Check size={11} strokeWidth={2} /> {t('common.select')}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
{selectMode && (
|
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 8, padding: '6px 8px', borderRadius: 8, background: 'var(--bg-tertiary)', fontSize: 11 }}>
|
|
||||||
<span style={{ flex: 1, color: 'var(--text-muted)', fontWeight: 500 }}>
|
|
||||||
{t('places.selectionCount', { count: selectedIds.size })}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedIds.size === filtered.length) {
|
|
||||||
setSelectedIds(new Set())
|
|
||||||
} else {
|
|
||||||
setSelectedIds(new Set(filtered.map(p => p.id)))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
style={{ background: 'none', border: 'none', cursor: 'pointer', color: 'var(--text-muted)', fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4 }}
|
|
||||||
>
|
|
||||||
{selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (selectedIds.size === 0) return
|
|
||||||
if (isMobile) {
|
|
||||||
setPendingDeleteIds(Array.from(selectedIds))
|
|
||||||
} else {
|
|
||||||
onBulkDeletePlaces?.(Array.from(selectedIds))
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={selectedIds.size === 0}
|
|
||||||
style={{
|
|
||||||
display: 'flex', alignItems: 'center', gap: 4, background: 'none', border: 'none',
|
|
||||||
cursor: selectedIds.size > 0 ? 'pointer' : 'default',
|
|
||||||
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
|
||||||
fontSize: 11, fontFamily: 'inherit', padding: '2px 4px', borderRadius: 4, fontWeight: 500,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 size={11} strokeWidth={2} /> {t('places.deleteSelected')}
|
|
||||||
</button>
|
|
||||||
<button onClick={exitSelectMode} style={{ background: 'none', border: 'none', cursor: 'pointer', display: 'flex', padding: 2 }}>
|
|
||||||
<X size={12} strokeWidth={2} color="var(--text-faint)" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>}
|
</>}
|
||||||
|
|
||||||
{/* Filter-Tabs */}
|
{/* Filter-Tabs */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 8 }}>
|
{(() => {
|
||||||
{([{ id: 'all', label: t('places.all') }, { id: 'unplanned', label: t('places.unplanned') }, hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null] as const).filter(Boolean).map(f => (
|
const baseFiltered = places.filter(p => {
|
||||||
<button key={f.id} onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }} style={{
|
if (categoryFilters.size > 0) {
|
||||||
padding: '4px 10px', borderRadius: 20, border: 'none', cursor: 'pointer',
|
if (p.category_id == null) {
|
||||||
fontSize: 11, fontWeight: 500, fontFamily: 'inherit',
|
if (!categoryFilters.has('uncategorized')) return false
|
||||||
background: filter === f.id ? 'var(--accent)' : 'var(--bg-tertiary)',
|
} else if (!categoryFilters.has(String(p.category_id))) return false
|
||||||
color: filter === f.id ? 'var(--accent-text)' : 'var(--text-muted)',
|
}
|
||||||
}}>{f.label}</button>
|
if (search && !p.name.toLowerCase().includes(search.toLowerCase()) &&
|
||||||
))}
|
!(p.address || '').toLowerCase().includes(search.toLowerCase())) return false
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
const counts = {
|
||||||
|
all: baseFiltered.length,
|
||||||
|
unplanned: baseFiltered.filter(p => !plannedIds.has(p.id)).length,
|
||||||
|
tracks: baseFiltered.filter(p => p.route_geometry).length,
|
||||||
|
}
|
||||||
|
const tabs = ([
|
||||||
|
{ id: 'all', label: t('places.all') },
|
||||||
|
{ id: 'unplanned', label: t('places.unplanned') },
|
||||||
|
hasTracks ? { id: 'tracks', label: t('places.filterTracks') } : null,
|
||||||
|
] as const).filter(Boolean) as Array<{ id: 'all' | 'unplanned' | 'tracks'; label: string }>
|
||||||
|
return (
|
||||||
|
<div style={{ display: 'flex', gap: 6, marginBottom: 8, flexWrap: 'wrap' }}>
|
||||||
|
{tabs.map(f => {
|
||||||
|
const active = filter === f.id
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={f.id}
|
||||||
|
onClick={() => { setFilter(f.id); onPlacesFilterChange?.(f.id); setSelectedIds(new Set()) }}
|
||||||
|
style={{
|
||||||
|
appearance: 'none', border: 'none', cursor: 'pointer', fontFamily: 'inherit',
|
||||||
|
display: 'inline-flex', alignItems: 'center', gap: 5,
|
||||||
|
padding: '4px 9px', borderRadius: 99,
|
||||||
|
fontSize: 11, fontWeight: 500, whiteSpace: 'nowrap',
|
||||||
|
background: active ? 'var(--accent)' : 'var(--bg-card)',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-primary)',
|
||||||
|
boxShadow: active ? 'none' : '0 1px 2px rgba(0,0,0,0.06)',
|
||||||
|
transition: 'background 0.15s, color 0.15s, box-shadow 0.15s',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
<span style={{
|
||||||
|
fontSize: 9, fontWeight: 600, lineHeight: 1,
|
||||||
|
background: active ? 'color-mix(in srgb, var(--accent-text) 22%, transparent)' : 'var(--bg-tertiary)',
|
||||||
|
color: active ? 'var(--accent-text)' : 'var(--text-faint)',
|
||||||
|
padding: '1px 5px', borderRadius: 99, minWidth: 14, textAlign: 'center',
|
||||||
|
}}>
|
||||||
|
{counts[f.id]}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
{/* Suchfeld */}
|
{/* Suchfeld */}
|
||||||
<div style={{ position: 'relative' }}>
|
<div style={{ position: 'relative' }}>
|
||||||
@@ -470,9 +462,9 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
? (categoryFilters.has('uncategorized') ? t('places.noCategory') : categories.find(c => categoryFilters.has(String(c.id)))?.name || t('places.allCategories'))
|
||||||
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
: `${categoryFilters.size} ${t('places.categoriesSelected')}`
|
||||||
return (
|
return (
|
||||||
<div style={{ marginTop: 6, position: 'relative' }}>
|
<div style={{ marginTop: 6, position: 'relative', display: 'flex', gap: 6, alignItems: 'stretch' }}>
|
||||||
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
<button onClick={() => setCatDropOpen(v => !v)} style={{
|
||||||
width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
flex: 1, minWidth: 0, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
|
||||||
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
padding: '6px 10px', borderRadius: 8, border: '1px solid var(--border-primary)',
|
||||||
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
background: 'var(--bg-card)', fontSize: 12, color: 'var(--text-primary)',
|
||||||
cursor: 'pointer', fontFamily: 'inherit',
|
cursor: 'pointer', fontFamily: 'inherit',
|
||||||
@@ -480,6 +472,41 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
|
||||||
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
<ChevronDown size={12} style={{ flexShrink: 0, color: 'var(--text-faint)', transform: catDropOpen ? 'rotate(180deg)' : 'none', transition: 'transform 0.15s' }} />
|
||||||
</button>
|
</button>
|
||||||
|
{canEditPlaces && (
|
||||||
|
<Tooltip label={t('common.select')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => { setSelectMode(v => !v); setSelectedIds(new Set()) }}
|
||||||
|
aria-label={t('common.select')}
|
||||||
|
aria-pressed={selectMode}
|
||||||
|
style={{
|
||||||
|
position: 'relative', width: 30, flexShrink: 0, borderRadius: 8,
|
||||||
|
border: `1px solid ${selectMode ? 'var(--accent)' : 'var(--border-primary)'}`,
|
||||||
|
background: selectMode ? 'color-mix(in srgb, var(--accent) 14%, transparent)' : 'var(--bg-card)',
|
||||||
|
color: selectMode ? 'var(--accent)' : 'var(--text-faint)',
|
||||||
|
cursor: 'pointer', fontFamily: 'inherit', padding: 0,
|
||||||
|
transition: 'background 0.18s, color 0.18s, border-color 0.18s',
|
||||||
|
overflow: 'hidden',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: selectMode ? 0 : 1,
|
||||||
|
transform: selectMode ? 'rotate(-90deg) scale(0.6)' : 'rotate(0) scale(1)',
|
||||||
|
}}>
|
||||||
|
<Check size={13} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
<span style={{
|
||||||
|
position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
transition: 'opacity 0.18s ease, transform 0.22s cubic-bezier(0.34, 1.56, 0.64, 1)',
|
||||||
|
opacity: selectMode ? 1 : 0,
|
||||||
|
transform: selectMode ? 'rotate(0) scale(1)' : 'rotate(90deg) scale(0.6)',
|
||||||
|
}}>
|
||||||
|
<X size={13} strokeWidth={2.4} />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
{catDropOpen && (
|
{catDropOpen && (
|
||||||
<div style={{
|
<div style={{
|
||||||
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
position: 'absolute', top: '100%', left: 0, right: 0, zIndex: 50, marginTop: 4,
|
||||||
@@ -550,10 +577,62 @@ const PlacesSidebar = React.memo(function PlacesSidebar({
|
|||||||
})()}
|
})()}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Anzahl */}
|
{/* Anzahl / Auswahl-Leiste */}
|
||||||
|
{selectMode ? (
|
||||||
|
<div style={{
|
||||||
|
margin: '6px 16px', padding: '5px 8px 5px 10px', borderRadius: 8,
|
||||||
|
background: 'color-mix(in srgb, var(--accent) 10%, transparent)',
|
||||||
|
display: 'flex', alignItems: 'center', gap: 4, flexShrink: 0, fontSize: 11,
|
||||||
|
}}>
|
||||||
|
<span style={{ flex: 1, color: 'var(--accent)', fontWeight: 600, whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis' }}>
|
||||||
|
{t('places.selectionCount', { count: selectedIds.size })}
|
||||||
|
</span>
|
||||||
|
<Tooltip label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.size === filtered.length) setSelectedIds(new Set())
|
||||||
|
else setSelectedIds(new Set(filtered.map(p => p.id)))
|
||||||
|
}}
|
||||||
|
aria-label={selectedIds.size === filtered.length && filtered.length > 0 ? t('common.deselectAll') : t('common.selectAll')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||||
|
background: 'transparent', color: 'var(--text-muted)', cursor: 'pointer', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { e.currentTarget.style.background = 'var(--bg-hover)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Check size={13} strokeWidth={2.2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip label={t('places.deleteSelected')} placement="bottom">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedIds.size === 0) return
|
||||||
|
if (isMobile) setPendingDeleteIds(Array.from(selectedIds))
|
||||||
|
else onBulkDeletePlaces?.(Array.from(selectedIds))
|
||||||
|
}}
|
||||||
|
disabled={selectedIds.size === 0}
|
||||||
|
aria-label={t('places.deleteSelected')}
|
||||||
|
style={{
|
||||||
|
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||||
|
width: 24, height: 24, borderRadius: 6, border: 'none',
|
||||||
|
background: 'transparent',
|
||||||
|
color: selectedIds.size > 0 ? '#ef4444' : 'var(--text-faint)',
|
||||||
|
cursor: selectedIds.size > 0 ? 'pointer' : 'default', padding: 0,
|
||||||
|
}}
|
||||||
|
onMouseEnter={e => { if (selectedIds.size > 0) e.currentTarget.style.background = 'color-mix(in srgb, #ef4444 14%, transparent)' }}
|
||||||
|
onMouseLeave={e => { e.currentTarget.style.background = 'transparent' }}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} strokeWidth={2} />
|
||||||
|
</button>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
<div style={{ padding: '6px 16px', flexShrink: 0 }}>
|
||||||
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
<span style={{ fontSize: 11, color: 'var(--text-faint)' }}>{filtered.length === 1 ? t('places.countSingular') : t('places.count', { count: filtered.length })}</span>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Liste */}
|
{/* Liste */}
|
||||||
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
<div style={{ flex: 1, overflowY: 'auto', minHeight: 0 }}>
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react'
|
||||||
|
import ReactDOM from 'react-dom'
|
||||||
|
|
||||||
|
type Placement = 'top' | 'bottom' | 'left' | 'right'
|
||||||
|
|
||||||
|
interface TooltipProps {
|
||||||
|
label: string
|
||||||
|
placement?: Placement
|
||||||
|
delay?: number
|
||||||
|
disabled?: boolean
|
||||||
|
children: React.ReactElement
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Tooltip({ label, placement = 'bottom', delay = 250, disabled, children }: TooltipProps) {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
const [coords, setCoords] = useState<{ top: number; left: number } | null>(null)
|
||||||
|
const triggerRef = useRef<HTMLElement | null>(null)
|
||||||
|
const tooltipRef = useRef<HTMLDivElement | null>(null)
|
||||||
|
const timerRef = useRef<number | null>(null)
|
||||||
|
|
||||||
|
const show = () => {
|
||||||
|
if (disabled || !label) return
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
timerRef.current = window.setTimeout(() => setOpen(true), delay)
|
||||||
|
}
|
||||||
|
const hide = () => {
|
||||||
|
if (timerRef.current) window.clearTimeout(timerRef.current)
|
||||||
|
setOpen(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => () => { if (timerRef.current) window.clearTimeout(timerRef.current) }, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !triggerRef.current) return
|
||||||
|
const r = triggerRef.current.getBoundingClientRect()
|
||||||
|
const tipW = tooltipRef.current?.offsetWidth ?? 0
|
||||||
|
const tipH = tooltipRef.current?.offsetHeight ?? 0
|
||||||
|
const gap = 6
|
||||||
|
let top = 0, left = 0
|
||||||
|
if (placement === 'top') { top = r.top - tipH - gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||||
|
else if (placement === 'bottom') { top = r.bottom + gap; left = r.left + r.width / 2 - tipW / 2 }
|
||||||
|
else if (placement === 'left') { top = r.top + r.height / 2 - tipH / 2; left = r.left - tipW - gap }
|
||||||
|
else { top = r.top + r.height / 2 - tipH / 2; left = r.right + gap }
|
||||||
|
const pad = 6
|
||||||
|
left = Math.max(pad, Math.min(left, window.innerWidth - tipW - pad))
|
||||||
|
top = Math.max(pad, Math.min(top, window.innerHeight - tipH - pad))
|
||||||
|
setCoords({ top, left })
|
||||||
|
}, [open, placement, label])
|
||||||
|
|
||||||
|
const child = React.Children.only(children)
|
||||||
|
const trigger = React.cloneElement(child, {
|
||||||
|
ref: (node: HTMLElement | null) => {
|
||||||
|
triggerRef.current = node
|
||||||
|
const r = (child as any).ref
|
||||||
|
if (typeof r === 'function') r(node)
|
||||||
|
else if (r && typeof r === 'object') r.current = node
|
||||||
|
},
|
||||||
|
onMouseEnter: (e: any) => { show(); child.props.onMouseEnter?.(e) },
|
||||||
|
onMouseLeave: (e: any) => { hide(); child.props.onMouseLeave?.(e) },
|
||||||
|
onFocus: (e: any) => { show(); child.props.onFocus?.(e) },
|
||||||
|
onBlur: (e: any) => { hide(); child.props.onBlur?.(e) },
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{trigger}
|
||||||
|
{open && ReactDOM.createPortal(
|
||||||
|
<div
|
||||||
|
ref={tooltipRef}
|
||||||
|
role="tooltip"
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: coords?.top ?? -9999,
|
||||||
|
left: coords?.left ?? -9999,
|
||||||
|
visibility: coords ? 'visible' : 'hidden',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
zIndex: 100000,
|
||||||
|
background: 'var(--bg-card, #ffffff)',
|
||||||
|
color: 'var(--text-primary, #111827)',
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: 500,
|
||||||
|
padding: '5px 10px',
|
||||||
|
borderRadius: 8,
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
|
||||||
|
border: '1px solid var(--border-faint, #e5e7eb)',
|
||||||
|
fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Text', system-ui, sans-serif",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</div>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Tooltip
|
||||||
@@ -932,7 +932,7 @@ const de: Record<string, string | { name: string; category: string }[]> = {
|
|||||||
|
|
||||||
// Places Sidebar
|
// Places Sidebar
|
||||||
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
'places.addPlace': 'Ort/Aktivität hinzufügen',
|
||||||
'places.importFile': 'Datei importieren',
|
'places.importFile': 'Dateimport',
|
||||||
'places.sidebarDrop': 'Ablegen zum Importieren',
|
'places.sidebarDrop': 'Ablegen zum Importieren',
|
||||||
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
'places.importFileHint': '.gpx-, .kml- oder .kmz-Dateien aus Tools wie Google My Maps, Google Earth oder einem GPS-Tracker importieren.',
|
||||||
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
'places.importFileDropHere': 'Datei auswählen oder hierher ziehen und ablegen',
|
||||||
|
|||||||
Reference in New Issue
Block a user