fix: UndoToast now emits expire after 5s so toast self-dismisses
This commit is contained in:
parent
82eeb4defc
commit
92da5902ba
3 changed files with 60 additions and 5 deletions
|
|
@ -71,4 +71,19 @@ describe('UndoToast', () => {
|
||||||
const w = mount(UndoToast, { props: { action: labelAction } })
|
const w = mount(UndoToast, { props: { action: labelAction } })
|
||||||
expect(w.find('[role="status"]').exists()).toBe(true)
|
expect(w.find('[role="status"]').exists()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('emits expire when tick fires with timestamp beyond DURATION', async () => {
|
||||||
|
let capturedTick: FrameRequestCallback | null = null
|
||||||
|
vi.stubGlobal('requestAnimationFrame', (fn: FrameRequestCallback) => {
|
||||||
|
capturedTick = fn
|
||||||
|
return 1
|
||||||
|
})
|
||||||
|
vi.spyOn(performance, 'now').mockReturnValue(0)
|
||||||
|
const w = mount(UndoToast, { props: { action: labelAction } })
|
||||||
|
await import('vue').then(v => v.nextTick())
|
||||||
|
// Simulate a tick timestamp 6 seconds in — beyond the 5-second DURATION
|
||||||
|
if (capturedTick) capturedTick(6000)
|
||||||
|
await import('vue').then(v => v.nextTick())
|
||||||
|
expect(w.emitted('expire')).toBeTruthy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import { ref, onMounted, onUnmounted, computed } from 'vue'
|
||||||
import type { LastAction } from '../stores/label'
|
import type { LastAction } from '../stores/label'
|
||||||
|
|
||||||
const props = defineProps<{ action: LastAction }>()
|
const props = defineProps<{ action: LastAction }>()
|
||||||
defineEmits<{ undo: [] }>()
|
const emit = defineEmits<{ undo: []; expire: [] }>()
|
||||||
|
|
||||||
const DURATION = 5000
|
const DURATION = 5000
|
||||||
const elapsed = ref(0)
|
const elapsed = ref(0)
|
||||||
|
|
@ -30,14 +30,15 @@ const label = computed(() => {
|
||||||
})
|
})
|
||||||
|
|
||||||
function tick(ts: number) {
|
function tick(ts: number) {
|
||||||
if (!start) start = ts
|
|
||||||
elapsed.value = ts - start
|
elapsed.value = ts - start
|
||||||
if (elapsed.value < DURATION) {
|
if (elapsed.value < DURATION) {
|
||||||
raf = requestAnimationFrame(tick)
|
raf = requestAnimationFrame(tick)
|
||||||
|
} else {
|
||||||
|
emit('expire')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => { raf = requestAnimationFrame(tick) })
|
onMounted(() => { start = performance.now(); raf = requestAnimationFrame(tick) })
|
||||||
onUnmounted(() => cancelAnimationFrame(raf))
|
onUnmounted(() => cancelAnimationFrame(raf))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,19 @@
|
||||||
<template>
|
<template>
|
||||||
<div class="label-view">
|
<div class="label-view">
|
||||||
|
<!-- App bar -->
|
||||||
|
<div class="app-bar">
|
||||||
|
<span class="app-title">Avocet</span>
|
||||||
|
<span class="app-subtitle">Email Labeler</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Header bar -->
|
<!-- Header bar -->
|
||||||
<header class="lv-header">
|
<header class="lv-header">
|
||||||
<span class="queue-count">
|
<span class="queue-count">
|
||||||
<template v-if="store.totalRemaining > 0">
|
<span v-if="loading" class="queue-status">Loading…</span>
|
||||||
|
<template v-else-if="store.totalRemaining > 0">
|
||||||
{{ store.totalRemaining }} remaining
|
{{ store.totalRemaining }} remaining
|
||||||
</template>
|
</template>
|
||||||
|
<span v-else class="queue-status">Queue empty</span>
|
||||||
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
|
<span v-if="onRoll" class="badge badge-roll">🔥 On a roll!</span>
|
||||||
<span v-if="speedRound" class="badge badge-speed">⚡ Speed round!</span>
|
<span v-if="speedRound" class="badge badge-speed">⚡ Speed round!</span>
|
||||||
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
|
<span v-if="fiftyDeep" class="badge badge-fifty">🎯 Fifty deep!</span>
|
||||||
|
|
@ -59,6 +67,7 @@
|
||||||
v-if="store.lastAction"
|
v-if="store.lastAction"
|
||||||
:action="store.lastAction"
|
:action="store.lastAction"
|
||||||
@undo="handleUndo"
|
@undo="handleUndo"
|
||||||
|
@expire="store.clearLastAction()"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -115,7 +124,10 @@ async function fetchBatch() {
|
||||||
apiError.value = false
|
apiError.value = false
|
||||||
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
|
const { data, error } = await useApiFetch<{ items: any[]; total: number }>('/api/queue?limit=10')
|
||||||
loading.value = false
|
loading.value = false
|
||||||
if (error || !data) { apiError.value = true; return }
|
if (error || !data) {
|
||||||
|
apiError.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
store.queue = data.items
|
store.queue = data.items
|
||||||
store.totalRemaining = data.total
|
store.totalRemaining = data.total
|
||||||
|
|
||||||
|
|
@ -286,6 +298,33 @@ onUnmounted(() => {
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.app-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
border-bottom: 2px solid var(--color-border, #d0d7e8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-family: var(--font-display, var(--font-body, sans-serif));
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--app-primary, #2A6080);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-subtitle {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--color-text-secondary, #6b7a99);
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
|
||||||
|
.queue-status {
|
||||||
|
opacity: 0.6;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
.lv-header {
|
.lv-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue