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:
parent
4fa0de8753
commit
45a4191f01
11 changed files with 92 additions and 39 deletions
|
|
@ -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/`
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
Loading…
Reference in a new issue