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