mirror of
https://github.com/bretzel-app/crumbs.git
synced 2026-10-12 12:06:57 +02:00
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:
co-authored by
Claude Opus 4.6
parent
c82f720b02
commit
172ab88b33
+21
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user