- Custom tooltip directive (WCAG AAA) on every button in the app - Two-column timer layout with sticky hero and recent entries sidebar - Timer font selector with 16 monospace Google Fonts and live preview - UI font selector with 15+ Google Fonts - Close-to-tray and minimize-to-tray settings - New app icons (no-glow variants), platform icon set - Mini timer pop-out window - Favorites strip with drag-reorder and inline actions - Comprehensive README with feature documentation - Remove tracked files that belong in gitignore
162 lines
4.3 KiB
Vue
162 lines
4.3 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed, nextTick } from 'vue'
|
|
import { X } from 'lucide-vue-next'
|
|
|
|
interface Props {
|
|
modelValue: string
|
|
label?: string
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
label: '',
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: string]
|
|
}>()
|
|
|
|
const recording = ref(false)
|
|
const announcement = ref('')
|
|
const recorderRef = ref<HTMLDivElement | null>(null)
|
|
|
|
const isMac = navigator.platform.toUpperCase().includes('MAC')
|
|
|
|
const keyChips = computed(() => {
|
|
if (!props.modelValue) return []
|
|
return props.modelValue.split('+').map(k =>
|
|
k === 'CmdOrCtrl' ? (isMac ? 'Cmd' : 'Ctrl') : k
|
|
)
|
|
})
|
|
|
|
function startRecording() {
|
|
recording.value = true
|
|
announcement.value = 'Recording. Press your key combination.'
|
|
nextTick(() => {
|
|
recorderRef.value?.focus()
|
|
})
|
|
}
|
|
|
|
function cancelRecording() {
|
|
recording.value = false
|
|
announcement.value = ''
|
|
}
|
|
|
|
function onKeydown(e: KeyboardEvent) {
|
|
if (!recording.value) return
|
|
|
|
e.preventDefault()
|
|
e.stopPropagation()
|
|
|
|
if (e.key === 'Escape') {
|
|
cancelRecording()
|
|
return
|
|
}
|
|
|
|
// Ignore standalone modifier keys
|
|
const modifierKeys = ['Control', 'Shift', 'Alt', 'Meta']
|
|
if (modifierKeys.includes(e.key)) return
|
|
|
|
// Must have at least one modifier
|
|
const hasModifier = e.ctrlKey || e.metaKey || e.shiftKey || e.altKey
|
|
if (!hasModifier) return
|
|
|
|
// Build the shortcut string
|
|
const parts: string[] = []
|
|
|
|
if (e.ctrlKey || e.metaKey) {
|
|
parts.push('CmdOrCtrl')
|
|
}
|
|
if (e.shiftKey) {
|
|
parts.push('Shift')
|
|
}
|
|
if (e.altKey) {
|
|
parts.push('Alt')
|
|
}
|
|
|
|
// Normalize the key name
|
|
let key = e.key
|
|
if (key === ' ') {
|
|
key = 'Space'
|
|
} else if (key.length === 1) {
|
|
key = key.toUpperCase()
|
|
}
|
|
|
|
parts.push(key)
|
|
|
|
const combo = parts.join('+')
|
|
recording.value = false
|
|
emit('update:modelValue', combo)
|
|
announcement.value = `Shortcut set to ${combo}`
|
|
}
|
|
|
|
function clearShortcut() {
|
|
emit('update:modelValue', '')
|
|
announcement.value = 'Shortcut cleared'
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div role="group" :aria-label="label || 'Keyboard shortcut'" class="inline-flex items-center gap-2">
|
|
<!-- Key chips display -->
|
|
<div v-if="!recording && modelValue" class="flex items-center gap-1" aria-hidden="true">
|
|
<template v-for="(chip, index) in keyChips" :key="index">
|
|
<span
|
|
class="px-1.5 py-0.5 bg-bg-elevated border border-border-subtle rounded text-text-secondary font-mono text-[0.6875rem]"
|
|
>{{ chip }}</span>
|
|
<span v-if="index < keyChips.length - 1" class="text-text-tertiary text-[0.6875rem]">+</span>
|
|
</template>
|
|
</div>
|
|
|
|
<!-- Screen reader text -->
|
|
<span class="sr-only">Current shortcut: {{ modelValue || 'None' }}</span>
|
|
|
|
<!-- Recording capture area (focused div) -->
|
|
<div
|
|
v-if="recording"
|
|
ref="recorderRef"
|
|
tabindex="0"
|
|
role="application"
|
|
aria-label="Press your key combination"
|
|
@keydown="onKeydown"
|
|
@blur="cancelRecording"
|
|
class="flex items-center gap-2 px-3 py-1 border border-accent rounded-lg bg-bg-inset focus:outline-none focus:ring-1 focus:ring-accent"
|
|
>
|
|
<span class="text-[0.75rem] text-accent motion-safe:animate-pulse">Press keys...</span>
|
|
<button
|
|
type="button"
|
|
aria-label="Cancel recording"
|
|
@mousedown.prevent="cancelRecording"
|
|
class="text-[0.6875rem] text-text-tertiary hover:text-text-primary transition-colors"
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Record button -->
|
|
<button
|
|
v-if="!recording"
|
|
type="button"
|
|
aria-label="Record shortcut"
|
|
@click="startRecording"
|
|
class="px-2.5 py-1 text-[0.6875rem] font-medium border border-border-subtle text-text-secondary rounded-lg hover:bg-bg-elevated hover:text-text-primary transition-colors"
|
|
>
|
|
Record
|
|
</button>
|
|
|
|
<!-- Clear button -->
|
|
<button
|
|
v-if="!recording && modelValue"
|
|
type="button"
|
|
aria-label="Clear shortcut"
|
|
v-tooltip="'Clear shortcut'"
|
|
@click="clearShortcut"
|
|
class="w-5 h-5 flex items-center justify-center text-text-tertiary hover:text-text-primary transition-colors"
|
|
>
|
|
<X class="w-3.5 h-3.5" :stroke-width="2" aria-hidden="true" />
|
|
</button>
|
|
|
|
<!-- aria-live region for announcements -->
|
|
<div class="sr-only" aria-live="assertive" aria-atomic="true">{{ announcement }}</div>
|
|
</div>
|
|
</template>
|