feat(avocet): UndoToast — 5-second countdown, undo button, accessible
This commit is contained in:
parent
5114e6ac19
commit
e7f08ce685
2 changed files with 179 additions and 0 deletions
74
web/src/components/UndoToast.test.ts
Normal file
74
web/src/components/UndoToast.test.ts
Normal file
|
|
@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
105
web/src/components/UndoToast.vue
Normal file
105
web/src/components/UndoToast.vue
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
<template>
|
||||||
|
<div class="undo-toast" role="status" aria-live="polite">
|
||||||
|
<span class="toast-label">{{ label }}</span>
|
||||||
|
<button class="undo-btn" @click="$emit('undo')">↩ Undo</button>
|
||||||
|
<div class="timer-track" aria-hidden="true">
|
||||||
|
<div class="timer-bar" :style="{ width: `${progress}%` }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
|
import type { LastAction } from '../stores/label'
|
||||||
|
|
||||||
|
const props = defineProps<{ action: LastAction }>()
|
||||||
|
defineEmits<{ undo: [] }>()
|
||||||
|
|
||||||
|
const DURATION = 5000
|
||||||
|
const elapsed = ref(0)
|
||||||
|
let start = 0
|
||||||
|
let raf = 0
|
||||||
|
|
||||||
|
const progress = computed(() => Math.max(0, 100 - (elapsed.value / DURATION) * 100))
|
||||||
|
|
||||||
|
const label = computed(() => {
|
||||||
|
const name = props.action.item.subject
|
||||||
|
if (props.action.type === 'label') return `Labeled "${name}" as ${props.action.label}`
|
||||||
|
if (props.action.type === 'discard') return `Discarded "${name}"`
|
||||||
|
return `Skipped "${name}"`
|
||||||
|
})
|
||||||
|
|
||||||
|
function tick(ts: number) {
|
||||||
|
if (!start) start = ts
|
||||||
|
elapsed.value = ts - start
|
||||||
|
if (elapsed.value < DURATION) {
|
||||||
|
raf = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => { raf = requestAnimationFrame(tick) })
|
||||||
|
onUnmounted(() => cancelAnimationFrame(raf))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.undo-toast {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 1.5rem;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
min-width: 280px;
|
||||||
|
max-width: 480px;
|
||||||
|
background: var(--color-surface-overlay, #1a2338);
|
||||||
|
color: var(--color-text-inverse, #f0f4fc);
|
||||||
|
border-radius: var(--radius-toast, 0.75rem);
|
||||||
|
padding: 0.75rem 1rem 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
box-shadow: 0 4px 24px rgba(0,0,0,0.25);
|
||||||
|
z-index: 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-label {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-family: var(--font-body, sans-serif);
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.undo-btn {
|
||||||
|
flex-shrink: 0;
|
||||||
|
padding: 0.3rem 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
border: 1px solid var(--app-primary, #5A9DBF);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--app-primary, #5A9DBF);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s, color 0.15s;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
.undo-btn:hover {
|
||||||
|
background: var(--app-primary, #5A9DBF);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-track {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
height: 3px;
|
||||||
|
background: rgba(255,255,255,0.15);
|
||||||
|
border-radius: 0 0 0.75rem 0.75rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timer-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--app-primary, #5A9DBF);
|
||||||
|
transition: width 0.1s linear;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Loading…
Reference in a new issue