feat: timer save dialog for no-project and long-timer scenarios
This commit is contained in:
159
src/components/TimerSaveDialog.vue
Normal file
159
src/components/TimerSaveDialog.vue
Normal file
@@ -0,0 +1,159 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, nextTick, onUnmounted } from 'vue'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
import { useAnnouncer } from '../composables/useAnnouncer'
|
||||
import AppSelect from './AppSelect.vue'
|
||||
import { useProjectsStore } from '../stores/projects'
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
elapsedSeconds: number
|
||||
mode: 'no-project' | 'long-timer'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
save: [projectId: number, description: string]
|
||||
discard: []
|
||||
cancel: []
|
||||
}>()
|
||||
|
||||
const projectsStore = useProjectsStore()
|
||||
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
||||
const { announce } = useAnnouncer()
|
||||
const dialogRef = ref<HTMLElement | null>(null)
|
||||
const selectedProjectId = ref<number | null>(null)
|
||||
const description = ref('')
|
||||
|
||||
const formattedDuration = computed(() => {
|
||||
const h = Math.floor(props.elapsedSeconds / 3600)
|
||||
const m = Math.floor((props.elapsedSeconds % 3600) / 60)
|
||||
const parts: string[] = []
|
||||
if (h > 0) parts.push(`${h} hour${h !== 1 ? 's' : ''}`)
|
||||
if (m > 0) parts.push(`${m} minute${m !== 1 ? 's' : ''}`)
|
||||
return parts.join(' and ') || 'less than a minute'
|
||||
})
|
||||
|
||||
const spokenDuration = computed(() => formattedDuration.value)
|
||||
|
||||
watch(() => props.show, async (val) => {
|
||||
if (val) {
|
||||
selectedProjectId.value = null
|
||||
description.value = ''
|
||||
await nextTick()
|
||||
if (dialogRef.value) {
|
||||
activateTrap(dialogRef.value, { onDeactivate: () => emit('cancel') })
|
||||
}
|
||||
if (props.mode === 'no-project') {
|
||||
announce(`Timer stopped. Select a project to save ${spokenDuration.value} of tracked time.`)
|
||||
} else {
|
||||
announce(`Timer has been running for ${spokenDuration.value}. Stop and save?`)
|
||||
}
|
||||
} else {
|
||||
deactivateTrap()
|
||||
}
|
||||
})
|
||||
|
||||
onUnmounted(() => deactivateTrap())
|
||||
|
||||
function handleSave() {
|
||||
if (!selectedProjectId.value) return
|
||||
emit('save', selectedProjectId.value, description.value)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 bg-black/70 backdrop-blur-[4px] flex items-center justify-center p-4 z-50"
|
||||
@click.self="$emit('cancel')"
|
||||
>
|
||||
<div
|
||||
ref="dialogRef"
|
||||
role="alertdialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="timer-save-title"
|
||||
aria-describedby="timer-save-desc"
|
||||
class="bg-bg-surface border border-border-subtle rounded-lg shadow-[0_1px_3px_rgba(0,0,0,0.3)] w-full max-w-md p-6"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 id="timer-save-title" class="text-[1.125rem] font-semibold font-[family-name:var(--font-heading)] text-text-primary">
|
||||
{{ mode === 'no-project' ? 'Save Entry' : 'Long Timer' }}
|
||||
</h2>
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="p-1 text-text-tertiary hover:text-text-primary transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X class="w-4 h-4" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p id="timer-save-desc" class="text-[0.8125rem] text-text-secondary mb-4">
|
||||
<template v-if="mode === 'no-project'">
|
||||
No project was selected. Choose a project to save your tracked time.
|
||||
</template>
|
||||
<template v-else>
|
||||
The timer has been running for a long time. Would you like to stop and save?
|
||||
</template>
|
||||
</p>
|
||||
|
||||
<div class="mb-4 px-3 py-2 bg-bg-inset rounded-lg">
|
||||
<span class="text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em]">Duration</span>
|
||||
<p class="text-[1.25rem] font-[family-name:var(--font-timer)] text-accent-text font-medium" :aria-label="spokenDuration">
|
||||
{{ formattedDuration }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === 'no-project'" class="space-y-3 mb-6">
|
||||
<div>
|
||||
<label class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Project *</label>
|
||||
<AppSelect
|
||||
v-model="selectedProjectId"
|
||||
:options="projectsStore.projects"
|
||||
label-key="name"
|
||||
value-key="id"
|
||||
placeholder="Select project"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label for="timer-save-desc-input" class="block text-[0.6875rem] text-text-tertiary uppercase tracking-[0.08em] mb-1.5">Description</label>
|
||||
<input
|
||||
id="timer-save-desc-input"
|
||||
v-model="description"
|
||||
type="text"
|
||||
class="w-full px-3 py-2 bg-bg-inset border border-border-subtle rounded-lg text-[0.8125rem] text-text-primary focus:outline-none focus:border-border-visible"
|
||||
placeholder="What did you work on?"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
@click="$emit('discard')"
|
||||
class="px-4 py-2 text-[0.8125rem] text-text-secondary border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Discard
|
||||
</button>
|
||||
<button
|
||||
v-if="mode === 'long-timer'"
|
||||
@click="$emit('cancel')"
|
||||
class="px-4 py-2 text-[0.8125rem] text-text-secondary border border-border-subtle rounded-lg hover:bg-bg-elevated transition-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
Keep Running
|
||||
</button>
|
||||
<button
|
||||
@click="handleSave"
|
||||
:disabled="mode === 'no-project' && !selectedProjectId"
|
||||
class="px-4 py-2 text-[0.8125rem] bg-accent text-bg-base rounded-lg hover:bg-accent-hover transition-colors disabled:opacity-40 disabled:cursor-not-allowed focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent"
|
||||
>
|
||||
{{ mode === 'long-timer' ? 'Stop & Save' : 'Save' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
Reference in New Issue
Block a user