From e7f08ce6857e72f00411bd94176f438fb864f5b8 Mon Sep 17 00:00:00 2001 From: pyr0ball Date: Tue, 3 Mar 2026 16:13:02 -0800 Subject: [PATCH] =?UTF-8?q?feat(avocet):=20UndoToast=20=E2=80=94=205-secon?= =?UTF-8?q?d=20countdown,=20undo=20button,=20accessible?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- web/src/components/UndoToast.test.ts | 74 +++++++++++++++++++ web/src/components/UndoToast.vue | 105 +++++++++++++++++++++++++++ 2 files changed, 179 insertions(+) create mode 100644 web/src/components/UndoToast.test.ts create mode 100644 web/src/components/UndoToast.vue diff --git a/web/src/components/UndoToast.test.ts b/web/src/components/UndoToast.test.ts new file mode 100644 index 0000000..3378504 --- /dev/null +++ b/web/src/components/UndoToast.test.ts @@ -0,0 +1,74 @@ +import { mount } from '@vue/test-utils' +import UndoToast from './UndoToast.vue' +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' + +// Mock requestAnimationFrame for jsdom +beforeEach(() => { + vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => { + // Call with a fake timestamp to simulate one frame + setTimeout(() => fn(16), 0) + return 1 + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) +}) + +afterEach(() => { + vi.unstubAllGlobals() +}) + +const labelAction = { + type: 'label' as const, + item: { id: 'abc', subject: 'Interview at Acme', body: '...', from: 'hr@acme.com', date: '2026-03-01', source: 'imap:test' }, + label: 'interview_scheduled', +} + +const skipAction = { + type: 'skip' as const, + item: { id: 'xyz', subject: 'Cold Outreach', body: '...', from: 'recruiter@x.com', date: '2026-03-01', source: 'imap:test' }, +} + +const discardAction = { + type: 'discard' as const, + item: { id: 'def', subject: 'Spam Email', body: '...', from: 'spam@spam.com', date: '2026-03-01', source: 'imap:test' }, +} + +describe('UndoToast', () => { + it('renders subject for a label action', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.text()).toContain('Interview at Acme') + expect(w.text()).toContain('interview_scheduled') + }) + + it('renders subject for a skip action', () => { + const w = mount(UndoToast, { props: { action: skipAction } }) + expect(w.text()).toContain('Cold Outreach') + expect(w.text()).toContain('Skipped') + }) + + it('renders subject for a discard action', () => { + const w = mount(UndoToast, { props: { action: discardAction } }) + expect(w.text()).toContain('Spam Email') + expect(w.text()).toContain('Discarded') + }) + + it('has undo button', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('.undo-btn').exists()).toBe(true) + }) + + it('emits undo when button clicked', async () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + await w.find('.undo-btn').trigger('click') + expect(w.emitted('undo')).toBeTruthy() + }) + + it('has timer bar element', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('.timer-bar').exists()).toBe(true) + }) + + it('has accessible role=status', () => { + const w = mount(UndoToast, { props: { action: labelAction } }) + expect(w.find('[role="status"]').exists()).toBe(true) + }) +}) diff --git a/web/src/components/UndoToast.vue b/web/src/components/UndoToast.vue new file mode 100644 index 0000000..18e7f57 --- /dev/null +++ b/web/src/components/UndoToast.vue @@ -0,0 +1,105 @@ + + + + +