feat: tooltips, two-column timer, font selector, tray behavior, icons, readme
- 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
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Pipette } from 'lucide-vue-next'
|
||||
import { computeDropdownPosition } from '../utils/dropdown'
|
||||
import { useFocusTrap } from '../utils/focusTrap'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
@@ -16,6 +17,8 @@ const emit = defineEmits<{
|
||||
'update:modelValue': [value: string]
|
||||
}>()
|
||||
|
||||
const { activate: activateTrap, deactivate: deactivateTrap } = useFocusTrap()
|
||||
|
||||
const isOpen = ref(false)
|
||||
const triggerRef = ref<HTMLButtonElement | null>(null)
|
||||
const panelRef = ref<HTMLDivElement | null>(null)
|
||||
@@ -245,7 +248,7 @@ function onHuePointerUp() {
|
||||
|
||||
function updatePosition() {
|
||||
if (!triggerRef.value) return
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330 })
|
||||
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
|
||||
}
|
||||
|
||||
// ── Open / Close ────────────────────────────────────────────────────
|
||||
@@ -261,15 +264,18 @@ function open() {
|
||||
updatePosition()
|
||||
|
||||
nextTick(() => {
|
||||
updatePosition()
|
||||
drawGradient()
|
||||
drawHueStrip()
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
if (panelRef.value) activateTrap(panelRef.value)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
deactivateTrap()
|
||||
isOpen.value = false
|
||||
document.removeEventListener('click', onClickOutside, true)
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
@@ -296,6 +302,34 @@ onBeforeUnmount(() => {
|
||||
document.removeEventListener('scroll', onScrollOrResize, true)
|
||||
window.removeEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
|
||||
// ── Keyboard Handlers for Accessibility ─────────────────────────────
|
||||
|
||||
function onGradientKeydown(e: KeyboardEvent) {
|
||||
const step = 5
|
||||
let handled = false
|
||||
if (e.key === 'ArrowRight') { saturation.value = Math.min(100, saturation.value + step); handled = true }
|
||||
else if (e.key === 'ArrowLeft') { saturation.value = Math.max(0, saturation.value - step); handled = true }
|
||||
else if (e.key === 'ArrowUp') { brightness.value = Math.min(100, brightness.value + step); handled = true }
|
||||
else if (e.key === 'ArrowDown') { brightness.value = Math.max(0, brightness.value - step); handled = true }
|
||||
if (handled) {
|
||||
e.preventDefault()
|
||||
emitColor()
|
||||
}
|
||||
}
|
||||
|
||||
function onHueKeydown(e: KeyboardEvent) {
|
||||
const step = 5
|
||||
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
hue.value = Math.min(360, hue.value + step)
|
||||
emitColor()
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
hue.value = Math.max(0, hue.value - step)
|
||||
emitColor()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -305,6 +339,9 @@ onBeforeUnmount(() => {
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
aria-label="Color picker"
|
||||
:aria-expanded="isOpen"
|
||||
aria-haspopup="dialog"
|
||||
class="w-full flex items-center gap-2.5 px-3 py-2 bg-bg-inset border border-border-subtle rounded-xl text-[0.8125rem] text-left cursor-pointer transition-colors"
|
||||
:style="
|
||||
isOpen
|
||||
@@ -317,20 +354,26 @@ onBeforeUnmount(() => {
|
||||
"
|
||||
>
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="'Current color: ' + (modelValue?.toUpperCase() || '#000000')"
|
||||
class="w-5 h-5 rounded-md border border-border-subtle shrink-0"
|
||||
:style="{ backgroundColor: modelValue }"
|
||||
/>
|
||||
<span class="text-text-primary font-mono text-[0.75rem] tracking-wide">{{ modelValue?.toUpperCase() || '#000000' }}</span>
|
||||
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" />
|
||||
<Pipette class="w-4 h-4 text-text-secondary shrink-0 ml-auto" :stroke-width="2" aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Teleport to="body">
|
||||
<Teleport to="#app">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Choose color"
|
||||
@keydown.escape.prevent="close"
|
||||
class="bg-bg-surface border border-border-visible rounded-xl shadow-[0_4px_24px_rgba(0,0,0,0.4)] overflow-hidden"
|
||||
>
|
||||
<!-- Preset swatches -->
|
||||
@@ -341,7 +384,9 @@ onBeforeUnmount(() => {
|
||||
:key="c"
|
||||
type="button"
|
||||
@click="selectPreset(c)"
|
||||
class="w-6 h-6 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
|
||||
:aria-label="'Color preset ' + c"
|
||||
:aria-pressed="currentHex === c.toUpperCase()"
|
||||
class="w-8 h-8 rounded-full border-2 transition-colors cursor-pointer hover:scale-110"
|
||||
:class="currentHex === c.toUpperCase() ? 'border-text-primary' : 'border-transparent'"
|
||||
:style="{ backgroundColor: c }"
|
||||
/>
|
||||
@@ -351,11 +396,15 @@ onBeforeUnmount(() => {
|
||||
<!-- Saturation/Brightness gradient -->
|
||||
<div class="px-3 pb-2">
|
||||
<div
|
||||
role="application"
|
||||
aria-label="Saturation and brightness"
|
||||
tabindex="0"
|
||||
class="relative rounded-lg overflow-hidden cursor-crosshair"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onGradientPointerDown"
|
||||
@pointermove="onGradientPointerMove"
|
||||
@pointerup="onGradientPointerUp"
|
||||
@keydown="onGradientKeydown"
|
||||
>
|
||||
<canvas
|
||||
ref="gradientRef"
|
||||
@@ -378,11 +427,18 @@ onBeforeUnmount(() => {
|
||||
<!-- Hue slider -->
|
||||
<div class="px-3 pb-2">
|
||||
<div
|
||||
role="slider"
|
||||
aria-label="Hue"
|
||||
:aria-valuenow="Math.round(hue)"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="360"
|
||||
tabindex="0"
|
||||
class="relative rounded-md overflow-hidden cursor-pointer"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onHuePointerDown"
|
||||
@pointermove="onHuePointerMove"
|
||||
@pointerup="onHuePointerUp"
|
||||
@keydown="onHueKeydown"
|
||||
>
|
||||
<canvas
|
||||
ref="hueRef"
|
||||
@@ -404,6 +460,8 @@ onBeforeUnmount(() => {
|
||||
<!-- Hex input + preview -->
|
||||
<div class="px-3 pb-3 flex items-center gap-2">
|
||||
<span
|
||||
role="img"
|
||||
:aria-label="'Selected color: ' + currentHex"
|
||||
class="w-8 h-8 rounded-lg border border-border-subtle shrink-0"
|
||||
:style="{ backgroundColor: currentHex }"
|
||||
/>
|
||||
@@ -412,6 +470,7 @@ onBeforeUnmount(() => {
|
||||
@input="onHexInput"
|
||||
type="text"
|
||||
maxlength="7"
|
||||
aria-label="Hex color value"
|
||||
class="flex-1 px-2.5 py-1.5 bg-bg-inset border border-border-subtle rounded-lg text-[0.75rem] text-text-primary font-mono tracking-wide placeholder-text-tertiary focus:outline-none focus:border-border-visible"
|
||||
placeholder="#D97706"
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user