feat: custom tooltip action replacing native title attributes

Add a reusable `use:tooltip` Svelte action as a drop-in replacement for
native title attributes. Styled to match the retro parchment theme with
JetBrains Mono font, dark background, and 400ms hover delay.

Replace all title attributes in NoteEditor, NoteCard, and NotesView
with the custom tooltip. The action supports top/bottom positioning
and auto-clamps to viewport edges.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Frederic Cabassut
2026-03-22 20:09:32 +01:00
co-authored by Claude Opus 4.6
parent c82f720b02
commit 172ab88b33
5 changed files with 122 additions and 15 deletions
+21
View File
@@ -203,3 +203,24 @@ body::before {
.prose ul.task-list li p {
margin: 0;
}
/* Custom tooltips */
.tooltip-popup {
position: fixed;
z-index: 9999;
padding: 0.25rem 0.5rem;
font-family: 'JetBrains Mono', monospace;
font-size: 0.7rem;
line-height: 1.2;
color: var(--bg-surface);
background: var(--text);
border-radius: 2px;
pointer-events: none;
white-space: nowrap;
opacity: 0;
transition: opacity 100ms ease-out;
}
.tooltip-visible {
opacity: 1;
}
+9 -8
View File
@@ -12,6 +12,7 @@
import Archive from 'lucide-svelte/icons/archive';
import ArchiveRestore from 'lucide-svelte/icons/archive-restore';
import UserMinus from 'lucide-svelte/icons/user-minus';
import { tooltip } from '$lib/utils/tooltip.js';
interface ChecklistItem {
text: string;
@@ -117,7 +118,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--border)]/10"
title="Unpin"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
@@ -160,7 +161,7 @@
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Restore"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
@@ -168,7 +169,7 @@
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete forever"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
@@ -178,7 +179,7 @@
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Pin"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
@@ -188,7 +189,7 @@
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Unarchive"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
@@ -197,7 +198,7 @@
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Archive"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
@@ -207,7 +208,7 @@
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Leave note"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
@@ -216,7 +217,7 @@
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--border)]/10"
title="Delete"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
+7 -6
View File
@@ -29,6 +29,7 @@
import ListX from 'lucide-svelte/icons/list-x';
import Archive from 'lucide-svelte/icons/archive';
import { parseChecklist, serializeChecklist } from '$lib/utils/checklist.js';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
note: Note | null;
@@ -415,7 +416,7 @@
<button
onclick={() => (showColorPicker = !showColorPicker)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Background color"
use:tooltip={"Background color"}
data-testid="color-picker-toggle"
>
<Palette class="h-5 w-5 text-[var(--text-muted)]" />
@@ -431,7 +432,7 @@
<button
onclick={toggleImageUpload}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Image attachments"
use:tooltip={"Attachments"}
data-testid="image-toggle"
>
<ImageIcon class="h-5 w-5 {showImageUpload ? 'text-[var(--primary)]' : ''}" />
@@ -442,7 +443,7 @@
<button
onclick={toggleShareDialog}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Share note"
use:tooltip={"Share"}
data-testid="share-toggle"
>
{#if hasPublicLink}
@@ -454,7 +455,7 @@
{/if}
</button>
{:else if isShared}
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" title="Shared note">
<span class="flex items-center gap-1 rounded-sm p-2 text-[var(--text-muted)]" use:tooltip={"Shared note"}>
<Users class="h-5 w-5" />
</span>
{/if}
@@ -464,7 +465,7 @@
<button
onclick={handleArchive}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="Archive"
use:tooltip={"Archive"}
data-testid="archive-note-btn"
>
<Archive class="h-5 w-5" />
@@ -476,7 +477,7 @@
bind:this={overflowBtnEl}
onclick={() => (showOverflowMenu = !showOverflowMenu)}
class="rounded-sm p-2 hover:bg-[var(--border)]/10"
title="More actions"
use:tooltip={"More"}
data-testid="overflow-menu-btn"
>
<EllipsisVertical class="h-5 w-5" />
+2 -1
View File
@@ -7,6 +7,7 @@
import type { Note, NoteFilter } from '$lib/types/index.js';
import Plus from 'lucide-svelte/icons/plus';
import SquareCheck from 'lucide-svelte/icons/square-check';
import { tooltip } from '$lib/utils/tooltip.js';
interface Props {
filter: NoteFilter;
@@ -70,7 +71,7 @@
<button
onclick={() => { newNoteChecklist = true; showNewNote = true; }}
class="cursor-pointer rounded-sm p-3 text-[var(--text-muted)] hover:text-[var(--primary)]"
title="New checklist"
use:tooltip={"New checklist"}
data-testid="new-checklist-btn"
>
<SquareCheck class="h-4 w-4" />
+83
View File
@@ -0,0 +1,83 @@
/**
* Custom tooltip action — drop-in replacement for title attributes.
* Usage: <button use:tooltip={"Label"}>
* Or with options: <button use:tooltip={{ text: "Label", position: "bottom" }}>
*/
export type TooltipPosition = 'top' | 'bottom';
interface TooltipOptions {
text: string;
position?: TooltipPosition;
}
function parseOptions(value: string | TooltipOptions): { text: string; position: TooltipPosition } {
if (typeof value === 'string') return { text: value, position: 'top' };
return { text: value.text, position: value.position ?? 'top' };
}
export function tooltip(node: HTMLElement, value: string | TooltipOptions) {
let opts = parseOptions(value);
let el: HTMLDivElement | null = null;
let timeout: ReturnType<typeof setTimeout> | undefined;
function show() {
timeout = setTimeout(() => {
if (!opts.text) return;
el = document.createElement('div');
el.className = 'tooltip-popup';
el.textContent = opts.text;
document.body.appendChild(el);
position();
}, 400);
}
function position() {
if (!el) return;
const rect = node.getBoundingClientRect();
const tipRect = el.getBoundingClientRect();
let top: number;
if (opts.position === 'bottom') {
top = rect.bottom + 6;
} else {
top = rect.top - tipRect.height - 6;
}
let left = rect.left + rect.width / 2 - tipRect.width / 2;
// Clamp to viewport
left = Math.max(8, Math.min(left, window.innerWidth - tipRect.width - 8));
el.style.top = `${top}px`;
el.style.left = `${left}px`;
el.classList.add('tooltip-visible');
}
function hide() {
clearTimeout(timeout);
if (el) {
el.remove();
el = null;
}
}
node.addEventListener('mouseenter', show);
node.addEventListener('mouseleave', hide);
node.addEventListener('pointerdown', hide);
// Remove native title to prevent double tooltip
const nativeTitle = node.getAttribute('title');
if (nativeTitle) node.removeAttribute('title');
return {
update(newValue: string | TooltipOptions) {
opts = parseOptions(newValue);
},
destroy() {
hide();
node.removeEventListener('mouseenter', show);
node.removeEventListener('mouseleave', hide);
node.removeEventListener('pointerdown', hide);
if (nativeTitle) node.setAttribute('title', nativeTitle);
}
};
}