- 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
160 lines
4.7 KiB
Vue
160 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, computed } from 'vue'
|
|
import { Minus, Plus } from 'lucide-vue-next'
|
|
|
|
interface Props {
|
|
modelValue: number
|
|
min?: number
|
|
max?: number
|
|
step?: number
|
|
precision?: number
|
|
prefix?: string
|
|
suffix?: string
|
|
label?: string
|
|
compact?: boolean
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
min: 0,
|
|
max: Infinity,
|
|
step: 1,
|
|
precision: 0,
|
|
prefix: '',
|
|
suffix: '',
|
|
label: 'Number input',
|
|
compact: false,
|
|
})
|
|
|
|
const emit = defineEmits<{
|
|
'update:modelValue': [value: number]
|
|
}>()
|
|
|
|
const isEditing = ref(false)
|
|
const editValue = ref('')
|
|
const inputRef = ref<HTMLInputElement | null>(null)
|
|
|
|
const displayValue = computed(() => {
|
|
return props.modelValue.toFixed(props.precision)
|
|
})
|
|
|
|
function setValue(val: number) {
|
|
const clamped = Math.min(props.max, Math.max(props.min, val))
|
|
const rounded = parseFloat(clamped.toFixed(props.precision))
|
|
emit('update:modelValue', rounded)
|
|
}
|
|
|
|
function increment() {
|
|
setValue(props.modelValue + props.step)
|
|
}
|
|
|
|
function decrement() {
|
|
setValue(props.modelValue - props.step)
|
|
}
|
|
|
|
// ── Press-and-hold ───────────────────────────────────────────────
|
|
let holdTimeout: ReturnType<typeof setTimeout> | null = null
|
|
let holdInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
function startHold(action: () => void) {
|
|
action()
|
|
holdTimeout = setTimeout(() => {
|
|
holdInterval = setInterval(action, 80)
|
|
}, 400)
|
|
}
|
|
|
|
function stopHold() {
|
|
if (holdTimeout) { clearTimeout(holdTimeout); holdTimeout = null }
|
|
if (holdInterval) { clearInterval(holdInterval); holdInterval = null }
|
|
}
|
|
|
|
// ── Inline editing ───────────────────────────────────────────────
|
|
function startEdit() {
|
|
isEditing.value = true
|
|
editValue.value = displayValue.value
|
|
setTimeout(() => inputRef.value?.select(), 0)
|
|
}
|
|
|
|
function commitEdit() {
|
|
isEditing.value = false
|
|
const parsed = parseFloat(editValue.value)
|
|
if (!isNaN(parsed)) {
|
|
setValue(parsed)
|
|
}
|
|
}
|
|
|
|
function cancelEdit() {
|
|
isEditing.value = false
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div
|
|
class="flex items-center"
|
|
:class="compact ? 'gap-1' : 'gap-2'"
|
|
role="group"
|
|
:aria-label="label"
|
|
>
|
|
<button
|
|
type="button"
|
|
aria-label="Decrease value"
|
|
v-tooltip="'Decrease'"
|
|
@mousedown.prevent="startHold(decrement)"
|
|
@mouseup="stopHold"
|
|
@mouseleave="stopHold"
|
|
@touchstart.prevent="startHold(decrement)"
|
|
@touchend="stopHold"
|
|
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
|
|
:disabled="modelValue <= min"
|
|
>
|
|
<Minus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
|
|
</button>
|
|
|
|
<div
|
|
v-if="!isEditing"
|
|
@click="startEdit"
|
|
@keydown.enter="startEdit"
|
|
@keydown.space.prevent="startEdit"
|
|
tabindex="0"
|
|
role="button"
|
|
:aria-label="'Edit value: ' + displayValue"
|
|
class="text-center font-mono text-text-primary cursor-text select-none"
|
|
:class="compact ? 'min-w-[2.5rem] text-[0.75rem]' : 'min-w-[4rem] text-[0.8125rem]'"
|
|
aria-live="polite"
|
|
>
|
|
<span v-if="prefix" class="text-text-tertiary">{{ prefix }}</span>
|
|
{{ displayValue }}
|
|
<span v-if="suffix" class="text-text-tertiary ml-0.5">{{ suffix }}</span>
|
|
</div>
|
|
<input
|
|
v-else
|
|
ref="inputRef"
|
|
v-model="editValue"
|
|
type="text"
|
|
inputmode="decimal"
|
|
:aria-label="label"
|
|
class="text-center px-1 py-0.5 bg-bg-inset border border-accent rounded-lg font-mono text-text-primary focus:outline-none"
|
|
:class="compact ? 'w-16 text-[0.75rem]' : 'w-20 text-[0.8125rem]'"
|
|
@blur="commitEdit"
|
|
@keydown.enter="commitEdit"
|
|
@keydown.escape="cancelEdit"
|
|
/>
|
|
|
|
<button
|
|
type="button"
|
|
aria-label="Increase value"
|
|
v-tooltip="'Increase'"
|
|
@mousedown.prevent="startHold(increment)"
|
|
@mouseup="stopHold"
|
|
@mouseleave="stopHold"
|
|
@touchstart.prevent="startHold(increment)"
|
|
@touchend="stopHold"
|
|
class="flex items-center justify-center border border-border-visible rounded-lg text-text-secondary hover:text-text-primary hover:bg-bg-elevated transition-colors disabled:opacity-40 disabled:cursor-not-allowed shrink-0"
|
|
:class="compact ? 'w-6 h-6' : 'w-8 h-8'"
|
|
:disabled="modelValue >= max"
|
|
>
|
|
<Plus :class="compact ? 'w-3 h-3' : 'w-3.5 h-3.5'" aria-hidden="true" :stroke-width="2" />
|
|
</button>
|
|
</div>
|
|
</template>
|