Files
crumbs/src/lib/components/NoteCard.svelte
T
Claude 3178d65b35 feat(a11y): RGAA 4.1 audit report, axe e2e gate and first fixes
Adds docs/ACCESSIBILITY.md (findings F1-F10 mapped to RGAA 4.1 and WCAG
2.1, what passed, what tooling cannot test) and tests/e2e/accessibility.spec.ts,
which runs axe-core WCAG 2.x A/AA scans over every screen in both themes
plus scripted checks for titles, landmarks, skip link, focus visibility
and keyboard reach. The spec is red until the remaining fixes land.

Fixes that need no design sign-off:
- F1: the tooltip action now exposes its text as aria-label on icon-only
  controls; removing `title` had left ~570 buttons unnamed.
- F5/F6/F7: checklist checkboxes and inputs, the editor textbox and the
  users-page role select get accessible names.
- F8: scrollable code blocks are keyboard reachable (tabindex=0), except
  in card previews which clip instead of scroll.
- F9: each settings tab has its own page title.
- F10: skip link to main content.

Still open, pending sign-off: F2 colour tokens, F3 card structure, F4
focus-visible styling.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014u5WimmZvshm9RJSd1c6wx
2026-09-18 06:08:16 +00:00

240 lines
8.0 KiB
Svelte

<script lang="ts">
import { getNoteColor } from '$lib/utils/colors.js';
import { getIsDarkMode } from '$lib/utils/theme.svelte.js';
import { renderMarkdown } from '$lib/utils/markdown.js';
import { togglePin, trashNote, archiveNote, unarchiveNote, restoreNote, deleteNote, leaveNote, currentFilter } from '$lib/stores/notes.js';
import ImageLightbox from './ImageLightbox.svelte';
import SharingIndicator from './SharingIndicator.svelte';
import type { Note } from '$lib/types/index.js';
import Undo2 from 'lucide-svelte/icons/undo-2';
import Trash2 from 'lucide-svelte/icons/trash-2';
import Bookmark from 'lucide-svelte/icons/bookmark';
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';
import { linkifyText } from '$lib/utils/checklist.js';
interface ChecklistItem {
text: string;
checked: boolean;
indented: boolean;
}
interface Props {
note: Note;
onEdit: (note: Note) => void;
fullHeight?: boolean;
}
let { note, onEdit, fullHeight = false }: Props = $props();
$effect(() => {
cardStyle = `background-color: ${getNoteColor(note.color, getIsDarkMode())}`;
});
let cardStyle = $state('');
let lightboxSrc = $state<string | null>(null);
let lightboxAlt = $state('');
const renderedContent = $derived(renderMarkdown(note.content, { focusableCode: false }));
const checklistItems = $derived<ChecklistItem[]>(
note.checklistMode
? note.content.split('\n').filter(l => l.trim()).map(line => {
const indented = line.startsWith(' - [');
return {
text: line.replace(/^ {0,2}- \[[ x]\] /, ''),
checked: /^ {0,2}- \[x\] /.test(line),
indented
};
})
: []
);
const activeChecklistItems = $derived(checklistItems.filter(i => !i.checked));
const doneChecklistItems = $derived(checklistItems.filter(i => i.checked));
const sortedChecklistItems = $derived([...activeChecklistItems, ...doneChecklistItems]);
const featuredAttachments = $derived((note.attachments ?? []).filter(a => a.featured));
function stop(fn: () => void) {
return (e: Event) => {
e.stopPropagation();
fn();
};
}
</script>
<!-- svelte-ignore a11y_no_noninteractive_element_to_interactive_role -->
<article
class="group relative cursor-pointer rounded-sm border border-[var(--border-subtle)] p-4 outline-none transition-all hover:border-[var(--primary)] shadow-[var(--card-shadow)] hover:shadow-[var(--card-shadow-hover)] max-h-[17rem] overflow-hidden {fullHeight ? 'h-full' : ''}"
style={cardStyle}
onclick={() => onEdit(note)}
onkeydown={(e) => e.key === 'Enter' && onEdit(note)}
role="button"
tabindex="0"
data-testid="note-card"
data-note-id={note.id}
>
<!-- Thumbnail strip (featured images only) -->
{#if featuredAttachments.length > 0}
<div class="-mx-4 -mt-4 mb-3 flex overflow-hidden rounded-t-sm" data-testid="card-thumbnails">
{#each featuredAttachments.slice(0, 3) as attachment}
<div class="relative min-w-0 flex-1">
<button
type="button"
class="h-24 w-full cursor-pointer p-0 border-0 bg-transparent"
onclick={(e) => { e.stopPropagation(); lightboxSrc = `/api/notes/${note.id}/attachments?attachmentId=${attachment.id}`; lightboxAlt = attachment.filename; }}
data-testid="card-thumbnail"
>
<img
src="/api/notes/{note.id}/attachments?attachmentId={attachment.id}&thumb=1"
alt={attachment.filename}
class="h-24 w-full object-cover"
loading="lazy"
/>
</button>
</div>
{/each}
{#if featuredAttachments.length > 3}
<div class="absolute right-1 top-1 rounded-sm bg-black/60 px-1.5 py-0.5 text-xs font-medium text-white" data-testid="card-thumbnail-count">
+{featuredAttachments.length - 3}
</div>
{/if}
</div>
{/if}
<!-- Status indicators (top-right) -->
<div class="absolute top-1.5 right-1.5 flex items-center gap-0.5">
{#if note.shareToken || (note.isShared && note.collaborators)}
<SharingIndicator
shareToken={note.shareToken}
collaborators={note.collaborators ?? []}
isOwner={note.isOwner ?? true}
/>
{/if}
{#if note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1 text-[var(--primary)] hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unpin"}
data-testid="pin-indicator"
>
<Bookmark class="h-4 w-4 fill-[var(--primary)]" />
</button>
{/if}
</div>
{#if note.title}
<h3 class="mb-2 text-sm font-semibold text-[var(--text)]">{note.title}</h3>
{/if}
{#if note.checklistMode && checklistItems.length > 0}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<ul class="space-y-2 mb-6" data-testid="note-checklist-preview"
onclick={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}
onkeydown={(e) => { if ((e.target as HTMLElement).closest('a')) e.stopPropagation(); }}>
{#each sortedChecklistItems.slice(0, 8) as item}
<li class="flex items-start gap-2 text-sm {item.indented ? 'pl-4 ' : ''}{item.checked ? 'text-[var(--text-muted)] line-through' : 'text-[var(--text)]'}"
data-testid={item.indented ? 'card-checklist-child' : undefined}>
<input
type="checkbox"
checked={item.checked}
disabled
tabindex="-1"
aria-hidden="true"
class="mt-0.5 h-3.5 w-3.5 shrink-0 rounded border-[var(--border-subtle)] text-[var(--primary)]"
data-testid="card-checklist-checkbox"
/>
<span class="break-words min-w-0">{@html linkifyText(item.text)}</span>
</li>
{/each}
{#if checklistItems.length > 8}
<li class="text-xs text-[var(--text-muted)]">+{checklistItems.length - 8} more</li>
{/if}
</ul>
{:else if note.content}
<div class="prose prose-sm line-clamp-6 max-w-none text-sm text-[var(--text-muted)]" data-testid="note-content-preview">
{@html renderedContent}
</div>
{/if}
<!-- Action buttons - show on hover -->
<div class="absolute bottom-1 right-1 flex gap-1 max-md:opacity-100 md:opacity-0 transition-opacity md:group-hover:opacity-100">
{#if $currentFilter === 'trashed'}
<button
onclick={stop(() => restoreNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Restore"}
data-testid="restore-btn"
>
<Undo2 class="h-4 w-4" />
</button>
<button
onclick={stop(() => deleteNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Delete forever"}
data-testid="delete-forever-btn"
>
<Trash2 class="h-4 w-4 text-[var(--destructive)]" />
</button>
{:else}
{#if !note.pinned}
<button
onclick={stop(() => togglePin(note.id, note.pinned))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Pin"}
data-testid="pin-btn"
>
<Bookmark class="h-4 w-4" />
</button>
{/if}
{#if $currentFilter === 'archived'}
<button
onclick={stop(() => unarchiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Unarchive"}
data-testid="unarchive-btn"
>
<ArchiveRestore class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => archiveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Archive"}
data-testid="archive-btn"
>
<Archive class="h-4 w-4" />
</button>
{/if}
{#if note.isShared && !note.isOwner}
<button
onclick={stop(() => leaveNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Leave note"}
data-testid="leave-btn"
>
<UserMinus class="h-4 w-4" />
</button>
{:else}
<button
onclick={stop(() => trashNote(note.id))}
class="rounded-sm p-1.5 hover:bg-[var(--hover-wash)]/10"
use:tooltip={"Trash"}
data-testid="trash-btn"
>
<Trash2 class="h-4 w-4" />
</button>
{/if}
{/if}
</div>
{#if lightboxSrc}
<ImageLightbox src={lightboxSrc} alt={lightboxAlt} onClose={() => lightboxSrc = null} />
{/if}
</article>