forked from mirror/crumbs-original
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
240 lines
8.0 KiB
Svelte
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>
|
|
|