Drei Actions, die in fast jedem Svelte-Projekt früher oder später nötig sind — hier als vollständige, fertig kopierbare Implementierungen mit Erklärung jeder Zeile. Tooltip für Hilfetexte, ClickOutside für selbstschließende Menüs, Focus-Trap für barrierefreie Modal-Dialoge. Alles TypeScript-typisiert, alles mit Cleanup und mit Blick auf Accessibility.
Tooltip
Ein Tooltip soll bei Hover und bei Tastatur-Fokus erscheinen — letzteres ist a11y-Pflicht. Wir bauen das mit einer Action, die einen kleinen Popover-Knoten ans Body anhängt und beim Verlassen wieder entfernt.
import type { Action } from 'svelte/action';
type TooltipParams = {
text: string;
placement?: 'top' | 'bottom';
};
export const tooltip: Action<HTMLElement, TooltipParams> = (node, params) => {
let popup: HTMLDivElement | null = null;
function show() {
if (popup) return;
popup = document.createElement('div');
popup.className = 'tooltip-popup';
popup.textContent = params.text;
popup.setAttribute('role', 'tooltip');
document.body.appendChild(popup);
const rect = node.getBoundingClientRect();
const placement = params.placement ?? 'top';
popup.style.position = 'fixed';
popup.style.left = `${rect.left + rect.width / 2}px`;
popup.style.top =
placement === 'top'
? `${rect.top - 8}px`
: `${rect.bottom + 8}px`;
popup.style.transform = 'translate(-50%, -100%)';
}
function hide() {
popup?.remove();
popup = null;
}
$effect(() => {
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('focus', show);
node.addEventListener('blur', hide);
return () => {
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('focus', show);
node.removeEventListener('blur', hide);
hide();
};
});
};Was hier wichtig ist:
- Hover und Focus behandeln — Tastatur-Nutzer dürfen nicht ausgegrenzt werden.
role="tooltip"für Screenreader.position: fixedmit Berechnung übergetBoundingClientRect()— das Tooltip lebt im<body>, nicht im Container, sodassoverflow: hiddenan Eltern-Elementen ihn nicht abschneidet.- Beim Cleanup: alle vier Listener entfernen und den Popover wieder aus dem DOM nehmen.
Verwendung
<script>
import { tooltip } from '$lib/actions/tooltip.svelte';
</script>
<button use:tooltip={{ text: 'Eintrag speichern' }}>
Speichern
</button>ClickOutside
Ein häufig gebrauchtes Pattern: Ein Dropdown soll sich schließen, wenn der Nutzer außerhalb klickt. Mit einer Action wird das ein Einzeiler in der Verwendung.
import type { Action } from 'svelte/action';
type ClickOutsideCallback = () => void;
export const clickOutside: Action<HTMLElement, ClickOutsideCallback> = (node, callback) => {
$effect(() => {
let onClick = callback;
function handle(event: MouseEvent) {
if (!node.contains(event.target as Node)) {
onClick();
}
}
// capture: true → früher Phase; verhindert Konflikte mit
// anderen Listenern, die `stopPropagation` aufrufen.
document.addEventListener('click', handle, { capture: true });
return () => document.removeEventListener('click', handle, { capture: true });
});
};Schritt für Schritt:
- Globaler Listener auf
document— fängt jeden Klick ab. node.contains(event.target)prüft, ob der Klick innerhalb des Action-Elements war. Falls nicht, ist es „außerhalb" — Callback ausführen.capture: truefängt das Event in der Capture-Phase, bevor Kind-Listener mitstopPropagation()es schlucken könnten. Das ist die typische Stolperfalle bei dieser Pattern-Implementierung.
Verwendung
<script>
import { clickOutside } from '$lib/actions/click-outside.svelte';
let open = $state(false);
</script>
<div use:clickOutside={() => open = false}>
<button onclick={() => open = !open}>Menü</button>
{#if open}
<ul>
<li>Eintrag 1</li>
<li>Eintrag 2</li>
</ul>
{/if}
</div>Klick auf den Button toggelt das Menü. Klick irgendwo anders schließt es automatisch.
Focus-Trap
Ein Focus-Trap hält den Tastatur-Fokus innerhalb eines bestimmten Bereichs — typisch für Modal-Dialoge. Ohne Trap kann der Nutzer mit Tab aus dem Modal raus auf darunterliegende Elemente, die er gar nicht sehen sollte. Das ist ein klassischer a11y-Fehler.
import type { Action } from 'svelte/action';
export const focusTrap: Action<HTMLElement> = (node) => {
$effect(() => {
const previousActive = document.activeElement as HTMLElement | null;
function getFocusable(): HTMLElement[] {
return Array.from(
node.querySelectorAll<HTMLElement>(
'a[href], button:not([disabled]), input:not([disabled]), ' +
'select:not([disabled]), textarea:not([disabled]), ' +
'[tabindex]:not([tabindex="-1"])',
),
);
}
function handleKey(event: KeyboardEvent) {
if (event.key !== 'Tab') return;
const focusable = getFocusable();
if (focusable.length === 0) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
node.addEventListener('keydown', handleKey);
// Initialer Fokus auf das erste fokussierbare Element
getFocusable()[0]?.focus();
return () => {
node.removeEventListener('keydown', handleKey);
previousActive?.focus(); // Fokus zurück, wo er vorher war
};
});
};Was hier passiert:
previousActivemerken — das Element, das vor dem Trap den Fokus hatte. Bei Cleanup geben wir den Fokus dorthin zurück (sonst verschwindet er ins Nichts).- Fokussierbare Elemente im Container ermitteln — Standard-Selector aus der Web-Accessibility-Welt.
- Tab-Handler bei Bedarf abfangen: Tab am letzten Element springt zurück zum ersten, Shift+Tab am ersten zum letzten. So bleibt der Fokus im Modal gefangen.
- Initial-Fokus auf das erste Element setzen — damit der Tab-Cycle direkt funktioniert.
Verwendung
<script>
import { focusTrap } from '$lib/actions/focus-trap.svelte';
let { open = $bindable(false) } = $props();
</script>
{#if open}
<div class="overlay" onclick={() => open = false}>
<div
class="dialog"
role="dialog"
aria-modal="true"
onclick={(e) => e.stopPropagation()}
use:focusTrap
>
<h2>Eintrag löschen?</h2>
<p>Diese Aktion kann nicht rückgängig gemacht werden.</p>
<div class="actions">
<button onclick={() => open = false}>Abbrechen</button>
<button onclick={() => open = false}>Löschen</button>
</div>
</div>
</div>
{/if}Sobald der Modal sichtbar wird, übernimmt der Trap. Der Nutzer kann den Modal nur über die im Markup vorgesehenen Wege verlassen (Buttons, Outside-Click, Escape — Letzteres müsstest du noch ergänzen).
Bonus: Auto-Resize-Textarea
Ein praktisches kleines Beispiel, das man in fast jedem Form-Builder braucht: Eine Textarea, deren Höhe sich automatisch dem Inhalt anpasst.
import type { Action } from 'svelte/action';
export const autoResize: Action<HTMLTextAreaElement> = (node) => {
$effect(() => {
function resize() {
node.style.height = 'auto';
node.style.height = `${node.scrollHeight}px`;
}
resize();
node.addEventListener('input', resize);
return () => node.removeEventListener('input', resize);
});
};<script>
import { autoResize } from '$lib/actions/auto-resize.svelte';
</script>
<textarea use:autoResize bind:value={text} rows="2"></textarea>Bei jeder Eingabe setzt die Action die Höhe zurück auf auto (sonst kann die Textarea nicht kleiner werden) und dann auf den scrollHeight — die natürliche Inhaltshöhe.
Bonus: Long-Press
Ein Tap-Halten erkennen — z. B. für Kontextmenüs oder Mehrfach-Selektion auf Touch-Geräten.
import type { Action } from 'svelte/action';
type LongPressParams = {
duration?: number;
onPress: () => void;
};
export const longPress: Action<HTMLElement, LongPressParams> = (node, params) => {
$effect(() => {
let timeoutId: number | undefined;
function start() {
timeoutId = window.setTimeout(() => {
params.onPress();
}, params.duration ?? 500);
}
function cancel() {
if (timeoutId) clearTimeout(timeoutId);
}
node.addEventListener('pointerdown', start);
node.addEventListener('pointerup', cancel);
node.addEventListener('pointerleave', cancel);
return () => {
cancel();
node.removeEventListener('pointerdown', start);
node.removeEventListener('pointerup', cancel);
node.removeEventListener('pointerleave', cancel);
};
});
};<script>
import { longPress } from '$lib/actions/long-press.svelte';
function handleLongPress() {
console.log('Lange gedrückt!');
}
</script>
<button use:longPress={{ onPress: handleLongPress, duration: 800 }}>
Halten
</button>Der pointerdown-Event startet einen Timer. Wird die Maus losgelassen oder verlässt das Element, bricht der Timer ab. Läuft er durch, wurde lange genug gedrückt — Callback feuert.
Häufige Stolperfallen bei Praxis-Actions
Tooltip am Container statt am Body anhängen.
Wenn der Container overflow: hidden hat, wird der Tooltip beschnitten. Anhängen an document.body (oder <svelte:head>) löst das.
ClickOutside ohne capture.
Listener im Inneren mit stopPropagation() können den Outside-Detect unterbrechen. capture: true fängt früher.
Focus-Trap ohne Initial-Fokus.
Ein Modal ohne automatisch gesetzten Initial-Fokus ist für Tastatur-Nutzer praktisch unbenutzbar.
Vergessen, den Fokus beim Cleanup zurückzugeben.
Wenn der Modal schließt und kein previousActive.focus() läuft, landet der Fokus im Nichts (oft am <body>). Sehr unangenehm für Tastatur-Nutzer.
Auto-Resize ohne auto-Reset.
textarea.style.height = scrollHeight macht die Box nur größer. Der vorherige Reset auf auto ist nötig, damit sie wieder kleiner werden kann.
Pointer-Events vs. Maus-Events bei Long-Press.
pointerdown/pointerup decken Touch und Maus gleichzeitig ab. mousedown/touchstart separat zu behandeln ist heute meistens unnötig.