refactor: Tooltips ueber tippy statt title-Attribute, appweit

title war fuer Hinweise die falsche Wahl: Es erscheint erst nach einer
Sekunde, sieht auf jedem System anders aus und laesst sich auf Touch-Geraeten
gar nicht aufrufen. Tippy war laengst als Abhaengigkeit da, aber kaum
benutzt.

<Button> nimmt jetzt eine tooltip-Prop, die zugleich als aria-label dient -
ohne das haetten die vielen Icon-Knoepfe beim Wegfall von title ihren Namen
fuer Screenreader verloren. Alle anderen Elemente nutzen die Action direkt.

Die Action selbst konnte bisher nur erzeugen. Sie beherrscht jetzt
Aktualisieren und Aufraeumen: Ein wechselnder Text ("Erledigt" / "Wieder
oeffnen") muss auch im Tooltip wechseln, und eine Instanz, die ihr Element
ueberlebt, haenge als leere Blase im Dokument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014dh9W1i7aLSdPYJzQPid5o
This commit is contained in:
Daniel Michelberger 2026-09-02 15:45:49 +02:00
parent 4fa0de8753
commit 45a4191f01
11 changed files with 92 additions and 39 deletions

View file

@ -115,6 +115,12 @@ The app uses Svelte 5 runes for state management with a custom store pattern:
**Hotkeys**: Use `app.hotkey(event, condition, callback)` which auto-ignores input fields and contenteditable elements.
**Tooltips**: Never use the `title` attribute for tooltips — appwide. `<Button>`
takes a `tooltip="…"` prop (it also becomes the `aria-label`, so icon-only
buttons keep an accessible name); every other element uses the action:
`use:tooltip={{ content: '…' }}` from `app.svelte.ts`. Both render through
tippy.js, which the action initializes, updates and destroys.
### Project Configuration
- **Repo-Struktur**: Monorepo mit `frontend/` (SvelteKit) und `backend/`

View file

@ -8,6 +8,7 @@
* beide dieselbe Auswahl zeigen.
*/
import FlagIcon from './FlagIcon.svelte'
import { tooltip } from '$lib/stores/app.svelte'
import { FLAG_ICON_SUGGESTIONS, iconExists } from '$lib/flagIcons'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
@ -48,8 +49,8 @@
{#each FLAG_ICON_SUGGESTIONS as name (name)}
<button
type="button"
title={name}
aria-label={name}
use:tooltip={{ content: name }}
aria-pressed={value === name}
class="grid place-items-center rounded-md p-1.5 hover:bg-accent"
class:bg-accent={value === name}

View file

@ -92,7 +92,7 @@
variant="ghost"
size="icon"
class="ml-auto size-7 shrink-0"
title={expanded ? 'Einklappen' : 'Alle anzeigen'}
tooltip={expanded ? 'Einklappen' : 'Alle anzeigen'}
onclick={() => (expanded = !expanded)}
>
{#if expanded}
@ -128,7 +128,7 @@
variant="ghost"
size="icon"
class="shrink-0"
title="Zeit erfassen"
tooltip="Zeit erfassen"
onclick={() => openTimeDialog(t.rider, t.run)}
>
<Timer class="size-4" />

View file

@ -38,10 +38,18 @@
WithElementRef<HTMLAnchorAttributes> & {
variant?: ButtonVariant;
size?: ButtonSize;
/**
* Hinweistext als Tooltip. Ersetzt `title` appweit und dient
* zugleich als Beschriftung, wenn der Knopf nur ein Icon zeigt —
* ohne das bliebe er für Screenreader namenlos.
*/
tooltip?: string;
};
</script>
<script lang="ts">
import { tooltip as tooltipAction } from "$lib/stores/app.svelte";
let {
class: className,
variant = "default",
@ -50,6 +58,7 @@
href = undefined,
type = "button",
disabled,
tooltip,
children,
...restProps
}: ButtonProps = $props();
@ -64,7 +73,9 @@
aria-disabled={disabled}
role={disabled ? "link" : undefined}
tabindex={disabled ? -1 : undefined}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</a>
@ -75,7 +86,9 @@
class={cn(buttonVariants({ variant, size }), className)}
{type}
{disabled}
aria-label={tooltip}
{...restProps}
use:tooltipAction={{ content: tooltip }}
>
{@render children?.()}
</button>

View file

@ -160,20 +160,46 @@ export const utils = {
},
}
/**
* Tooltip für ein Element. Appweit die einzige Art, einen Hinweis an einen
* Knopf zu hängen `title` überlassen wir dem Browser nicht mehr: Es
* erscheint erst nach einer Sekunde, sieht auf jedem System anders aus und
* lässt sich auf Touch-Geräten gar nicht aufrufen.
*
* Die Action beherrscht Aktualisieren und Aufräumen: Ein Text, der sich
* ändert (»Erledigt« / »Wieder öffnen«), muss auch im Tooltip wechseln, und
* eine Instanz, die ein entferntes Element überlebt, hinge als leere Blase
* im Dokument.
*/
export const tooltip = (
element: HTMLElement,
props: { active?: boolean; content?: string; showOnCreate?: boolean },
props: { active?: boolean; content?: string; showOnCreate?: boolean } = {},
) => {
let defaultProps = {
active: true,
const options = (p: typeof props) => ({
animation: 'shift-toward-subtle',
allowHTML: true,
allowHTML: false,
content: p.content ?? '',
showOnCreate: p.showOnCreate,
})
const wanted = (p: typeof props) => p.active !== false && !!p.content?.trim()
let instance = wanted(props) ? tippy(element, options(props)) : null
return {
update(next: typeof props) {
if (!wanted(next)) {
instance?.destroy()
instance = null
return
}
if (instance) instance.setProps(options(next))
else instance = tippy(element, options(next))
},
destroy() {
instance?.destroy()
instance = null
},
}
props = {...defaultProps, ...props}
if (props.showOnCreate) {
props = {
...props,
}
}
if (props.active) tippy(element, props)
}

View file

@ -1,7 +1,7 @@
<script lang="ts">
import { auth } from '$lib/stores/pocketbase.svelte'
import { goto } from '$app/navigation'
import { app } from '$lib/stores/app.svelte'
import { app, tooltip } from '$lib/stores/app.svelte'
import { setTeamContext } from '$lib/stores/teams.svelte'
import { setEventContext } from '$lib/stores/events.svelte'
import { setRunContext } from '$lib/stores/runs.svelte'
@ -146,8 +146,8 @@
<div class="flex items-center justify-between gap-3">
<a
href="/dashboard/settings"
title="Profil"
class="flex min-w-0 items-center gap-2"
use:tooltip={{ content: 'Profil' }}
>
<Avatar user={auth.user} size={24} />
<span class="truncate text-sm font-medium leading-none">
@ -161,7 +161,7 @@
size="icon"
class="size-7"
onclick={toggleMode}
title="Design wechseln"
tooltip="Design wechseln"
>
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
@ -174,7 +174,7 @@
size="icon"
class="size-7"
onclick={handleLogout}
title="Abmelden"
tooltip="Abmelden"
>
<LogOut class="h-4 w-4" />
</Button>
@ -191,7 +191,7 @@
size="icon"
class="md:hidden"
onclick={() => (isMobileMenuOpen = !isMobileMenuOpen)}
title="Menü"
tooltip="Menü"
>
{#if isMobileMenuOpen}
<X class="h-4 w-4" />
@ -229,14 +229,14 @@
<TeamSwitcher />
</div>
<div class="flex items-center gap-1 shrink-0">
<Button variant="ghost" size="icon" onclick={toggleMode} title="Design wechseln">
<Button variant="ghost" size="icon" onclick={toggleMode} tooltip="Design wechseln">
{#if mode.current === 'dark'}
<Sun class="h-4 w-4" />
{:else}
<Moon class="h-4 w-4" />
{/if}
</Button>
<Button variant="ghost" size="icon" onclick={handleLogout} title="Abmelden">
<Button variant="ghost" size="icon" onclick={handleLogout} tooltip="Abmelden">
<LogOut class="h-4 w-4" />
</Button>
</div>

View file

@ -142,10 +142,10 @@
</CardContent>
</a>
<div class="px-6 pb-4 flex items-center justify-end gap-1 border-t pt-3">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} title="Bearbeiten">
<Button variant="ghost" size="icon" onclick={() => openEdit(event)} tooltip="Bearbeiten">
<Pencil class="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} title="Löschen">
<Button variant="ghost" size="icon" onclick={() => events.remove(event)} tooltip="Löschen">
<Trash2 class="h-4 w-4 text-destructive" />
</Button>
</div>

View file

@ -229,18 +229,18 @@
</Table.Cell>
<Table.Cell class="text-right whitespace-nowrap">
{#if r.user}
<Button variant="ghost" size="icon" title="Verknüpfung lösen" onclick={() => doUnlink(r)}>
<Button variant="ghost" size="icon" tooltip="Verknüpfung lösen" onclick={() => doUnlink(r)}>
<Unlink class="size-4" />
</Button>
{:else}
<Button variant="ghost" size="icon" title="Konto verknüpfen" onclick={() => openLogin(r)}>
<Button variant="ghost" size="icon" tooltip="Konto verknüpfen" onclick={() => openLogin(r)}>
<KeyRound class="size-4" />
</Button>
{/if}
<Button variant="ghost" size="icon" title="Bearbeiten" onclick={() => openEdit(r)}>
<Button variant="ghost" size="icon" tooltip="Bearbeiten" onclick={() => openEdit(r)}>
<Pencil class="size-4" />
</Button>
<Button variant="ghost" size="icon" title="Löschen" onclick={() => riders.remove(r)}>
<Button variant="ghost" size="icon" tooltip="Löschen" onclick={() => riders.remove(r)}>
<Trash2 class="size-4 text-destructive" />
</Button>
</Table.Cell>

View file

@ -244,7 +244,7 @@
Verlassen
</Button>
{:else}
<Button variant="ghost" size="icon" title="Team löschen" onclick={() => teams.remove(t)}>
<Button variant="ghost" size="icon" tooltip="Team löschen" onclick={() => teams.remove(t)}>
<Trash2 class="size-4 text-destructive" />
</Button>
{/if}
@ -286,7 +286,7 @@
Zu Admin
</Button>
{/if}
<Button variant="ghost" size="icon" title="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<Button variant="ghost" size="icon" tooltip="Entfernen" onclick={() => doRemoveMember(t, userId)}>
<UserMinus class="size-4 text-destructive" />
</Button>
</div>

View file

@ -5,6 +5,7 @@
import { getTrailMarkerContext } from '$lib/stores/trailMarkers.svelte'
import TrailMap from '$lib/components/TrailMap.svelte'
import { trailStatus } from '$lib/trailStatus'
import { tooltip } from '$lib/stores/app.svelte'
import { getTrailCommentContext } from '$lib/stores/trailComments.svelte'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
@ -160,7 +161,9 @@
{#if open > 0}
<span
class="flex items-center gap-1 text-amber-600 ml-auto"
title="{open} offene Meldung{open === 1 ? '' : 'en'}"
use:tooltip={{
content: `${open} offene Meldung${open === 1 ? '' : 'en'}`,
}}
>
<AlertTriangle class="size-4" />
{open}
@ -177,9 +180,11 @@
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshMarkers}
class:font-medium={freshMarkers}
title={freshMarkers
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Marker'}
use:tooltip={{
content: freshMarkers
? `Marker, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Marker',
}}
>
<MapPin class="size-4" />
{markerCount}
@ -191,9 +196,11 @@
class="flex items-center gap-1.5"
class:text-muted-foreground={!freshComments}
class:font-medium={freshComments}
title={freshComments
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Kommentare'}
use:tooltip={{
content: freshComments
? `Kommentare, neue in den letzten ${RECENT_DAYS} Tagen`
: 'Kommentare',
}}
>
<MessageSquare class="size-4" />
{commentCount}

View file

@ -459,7 +459,7 @@
variant="ghost"
size="icon"
class="size-8"
title={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
tooltip={m.resolved ? 'Wieder öffnen' : 'Als erledigt markieren'}
onclick={async () => {
try {
await markers.toggleResolved(m.id)
@ -474,7 +474,7 @@
variant="ghost"
size="icon"
class="size-8"
title="Marker löschen"
tooltip="Marker löschen"
onclick={() => removeMarker(m)}
>
<Trash2 class="size-4 text-destructive" />
@ -583,7 +583,7 @@
</div>
{#if v.gpx}
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} title="GPX herunterladen">
<Button variant="ghost" size="icon" class="size-8" href={getFileURL(v, v.gpx)} tooltip="GPX herunterladen">
<Download class="size-4" />
</Button>
{/if}