feat(ext/popup): attachments-disclosure shared component

Compact disclosure rendering attachment rows with an action column
(× in edit, ↓ in view). Image-mime rows lazily decrypt + show a 16×16
thumb via object URLs; teardown revokes them on disclosure close. Edit
mode adds a "+ attach file" button wired to a hidden file input that
checks vault caps client-side before sending upload_attachment to SW.
6 new tests; total ~143.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-04-25 16:16:57 -04:00
parent b9c495cdea
commit c5f0449843
3 changed files with 363 additions and 0 deletions

View File

@@ -0,0 +1,73 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../../popup', async () => {
const sendMessage = vi.fn();
const escapeHtml = (s: string): string => s.replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]!));
return { sendMessage, escapeHtml };
});
import { renderAttachmentsDisclosure, wireAttachmentsDisclosure } from '../attachments-disclosure';
import { sendMessage } from '../../popup';
import type { AttachmentRef } from '../../../shared/types';
const REF1: AttachmentRef = { id: 'a1', filename: 'doc.pdf', mime_type: 'application/pdf', size: 12345, created: 1700000000 };
const REF2: AttachmentRef = { id: 'a2', filename: 'photo.png', mime_type: 'image/png', size: 240000, created: 1700000001 };
describe('attachments-disclosure render', () => {
it('renders empty state with no rows in edit mode', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
expect(html).toContain('attachments');
expect(html).toContain('+ attach file');
expect(html).not.toContain('attachment-row');
});
it('renders rows + remove buttons in edit mode', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange: vi.fn() });
expect(html).toContain('doc.pdf');
expect(html).toContain('photo.png');
expect(html).toContain('×');
expect(html).toContain('attachment-row__thumb'); // image-mime row gets thumb hook
});
it('renders rows + download buttons in view mode (no add btn)', () => {
const html = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
expect(html).toContain('↓');
expect(html).not.toContain('+ attach file');
});
});
describe('attachments-disclosure wiring', () => {
beforeEach(() => {
vi.mocked(sendMessage).mockReset();
});
it('clicking + attach triggers file input click', () => {
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
const fileInput = document.querySelector('.attachments-disclosure__file-input') as HTMLInputElement;
const clickSpy = vi.spyOn(fileInput, 'click');
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [], mode: 'edit', onChange: vi.fn() });
(document.querySelector('.attachment-add-btn') as HTMLButtonElement).click();
expect(clickSpy).toHaveBeenCalled();
});
it('clicking × calls onChange with the attachment removed', () => {
const onChange = vi.fn();
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1, REF2], mode: 'edit', onChange });
(document.querySelectorAll('.attachment-row__remove')[0] as HTMLElement).click();
expect(onChange).toHaveBeenCalledWith([REF2]);
});
it('clicking ↓ in view mode sends download_attachment', async () => {
vi.mocked(sendMessage).mockResolvedValueOnce({ ok: true, data: { bytes: new ArrayBuffer(10), filename: 'doc.pdf', mimeType: 'application/pdf' } });
document.body.innerHTML = renderAttachmentsDisclosure({ itemId: 'i1', attachments: [REF1], mode: 'view' });
wireAttachmentsDisclosure(document.body, { itemId: 'i1', attachments: [REF1], mode: 'view' });
(document.querySelector('.attachment-row__download') as HTMLElement).click();
await new Promise((r) => setTimeout(r, 50));
expect(vi.mocked(sendMessage)).toHaveBeenCalledWith(expect.objectContaining({
type: 'download_attachment',
itemId: 'i1',
attachmentId: 'a1',
}));
});
});