feat(chronik): render commentPreview in ChronikRow; add aria-label for screen readers
Some checks failed
CI / Unit & Component Tests (push) Failing after 3m49s
CI / OCR Service Tests (push) Successful in 45s
CI / Backend Unit Tests (push) Has been cancelled
CI / Unit & Component Tests (pull_request) Failing after 3m37s
CI / OCR Service Tests (pull_request) Successful in 48s
CI / Backend Unit Tests (pull_request) Failing after 3m21s

Replace the „…" placeholder with {item.commentPreview ?? '„…"'}. Plain-text
binding — no {@html} — as specified in the security note from issue #285.
Adds aria-label to the <a> wrapper for COMMENT_ADDED rows that carry a preview,
giving screen reader users the full context in one announcement.

Generated api.ts updated manually to include commentPreview?:string; will be
regenerated by npm run generate:api once the backend is running.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Marcel
2026-05-07 18:53:26 +02:00
parent e877847b7e
commit e3a3f209f9
3 changed files with 49 additions and 10 deletions

View File

@@ -108,6 +108,9 @@ const rowHref: string = $derived(
<a <a
href={rowHref} href={rowHref}
data-variant={variant} data-variant={variant}
aria-label={variant === 'comment' && item.commentPreview
? `${actorName} ${m.chronik_comment_added({ actor: '', doc: docTitle }).trim()} ${item.commentPreview}`
: undefined}
class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none class="group flex items-start gap-3 p-3 transition-colors hover:bg-muted/50 focus-visible:ring-2 focus-visible:ring-focus-ring focus-visible:outline-none
{variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}" {variant === 'for-you' ? 'border-l-[3px] border-accent bg-accent-bg/10' : ''}"
> >
@@ -159,20 +162,11 @@ const rowHref: string = $derived(
</p> </p>
{#if variant === 'comment'} {#if variant === 'comment'}
<!--
TODO: the backend does not yet expose a comment body preview on
ActivityFeedItemDTO. Render an ellipsis placeholder until it does —
duplicating the document title here looks like the comment is
quoting itself (Leonie, PR #288 review).
SECURITY: once item.commentPreview lands, render via {text}, never
{@html}. The backend must truncate and strip tags server-side (Nora,
issue #285 comment #3552).
-->
<p <p
data-testid="chronik-comment-preview" data-testid="chronik-comment-preview"
class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2" class="mt-1 line-clamp-1 font-serif text-sm text-ink-2 italic sm:line-clamp-2"
> >
&bdquo;&hellip;&ldquo; {item.commentPreview ?? '„…"'}
</p> </p>
{/if} {/if}

View File

@@ -186,6 +186,49 @@ describe('ChronikRow', () => {
expect(link).not.toBeNull(); expect(link).not.toBeNull();
}); });
// --- commentPreview content ---
it('renders commentPreview text when variant is comment and commentPreview is present', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'COMMENT_ADDED',
commentPreview: 'Hello family, great letter!'
};
render(ChronikRow, { item });
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
expect(preview).not.toBeNull();
expect(preview?.textContent).toContain('Hello family, great letter!');
});
it('renders placeholder ellipsis when variant is comment and commentPreview is null', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'COMMENT_ADDED',
commentPreview: undefined
};
render(ChronikRow, { item });
const preview = document.querySelector('[data-testid="chronik-comment-preview"]');
expect(preview).not.toBeNull();
expect(preview?.textContent?.trim()).toBe('„…"');
});
it('does not render preview paragraph for non-comment variants', async () => {
const item: ActivityFeedItemDTO = { ...baseItem, kind: 'TEXT_SAVED' };
render(ChronikRow, { item });
expect(document.querySelector('[data-testid="chronik-comment-preview"]')).toBeNull();
});
it('link has aria-label containing preview text for comment variant with preview', async () => {
const item: ActivityFeedItemDTO = {
...baseItem,
kind: 'COMMENT_ADDED',
commentPreview: 'A wonderful letter from grandma'
};
render(ChronikRow, { item });
const link = document.querySelector('a[aria-label]');
expect(link).not.toBeNull();
expect(link?.getAttribute('aria-label')).toContain('A wonderful letter from grandma');
});
// --- robustness: title rendering for edge cases --- // --- robustness: title rendering for edge cases ---
it('still renders the row link when documentTitle is an empty string', async () => { it('still renders the row link when documentTitle is an empty string', async () => {
// Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span // Felix: verbText.indexOf(docTitle) returned 0 for empty titles — the span

View File

@@ -2402,6 +2402,8 @@ export interface components {
* @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds. * @description Annotation associated with the comment; populated only for COMMENT_ADDED and MENTION_CREATED kinds.
*/ */
annotationId?: string; annotationId?: string;
/** @description Plain-text preview of the comment body (HTML stripped server-side, truncated to 120 chars); null for non-comment feed items or deleted comments. */
commentPreview?: string;
}; };
InvitePrefillDTO: { InvitePrefillDTO: {
firstName: string; firstName: string;