Files
zeroclock/src/components/AppColorPicker.vue
Your Name 514090eed4 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
2026-02-21 01:15:57 +02:00

483 lines
16 KiB
Vue

<script setup lang="ts">
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
presets?: string[]
}
const props = withDefaults(defineProps<Props>(), {
presets: () => ['#D97706', '#3B82F6', '#8B5CF6', '#EC4899', '#10B981', '#EF4444', '#06B6D4', '#6B7280'],
})
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)
const panelStyle = ref<Record<string, string>>({})
const hexInput = ref('')
// HSV state for the gradient picker
const hue = ref(0)
const saturation = ref(100)
const brightness = ref(100)
// Canvas refs
const gradientRef = ref<HTMLCanvasElement | null>(null)
const hueRef = ref<HTMLCanvasElement | null>(null)
// Dragging state
const draggingGradient = ref(false)
const draggingHue = ref(false)
// ── Color Conversion ────────────────────────────────────────────────
function hsvToRgb(h: number, s: number, v: number): [number, number, number] {
s /= 100
v /= 100
const c = v * s
const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
const m = v - c
let r = 0, g = 0, b = 0
if (h < 60) { r = c; g = x; b = 0 }
else if (h < 120) { r = x; g = c; b = 0 }
else if (h < 180) { r = 0; g = c; b = x }
else if (h < 240) { r = 0; g = x; b = c }
else if (h < 300) { r = x; g = 0; b = c }
else { r = c; g = 0; b = x }
return [
Math.round((r + m) * 255),
Math.round((g + m) * 255),
Math.round((b + m) * 255),
]
}
function rgbToHsv(r: number, g: number, b: number): [number, number, number] {
r /= 255; g /= 255; b /= 255
const max = Math.max(r, g, b)
const min = Math.min(r, g, b)
const d = max - min
let h = 0
if (d !== 0) {
if (max === r) h = 60 * (((g - b) / d) % 6)
else if (max === g) h = 60 * ((b - r) / d + 2)
else h = 60 * ((r - g) / d + 4)
}
if (h < 0) h += 360
const s = max === 0 ? 0 : (d / max) * 100
const v = max * 100
return [h, s, v]
}
function hexToRgb(hex: string): [number, number, number] | null {
const match = hex.match(/^#?([0-9a-f]{6})$/i)
if (!match) return null
const n = parseInt(match[1], 16)
return [(n >> 16) & 255, (n >> 8) & 255, n & 255]
}
function rgbToHex(r: number, g: number, b: number): string {
return '#' + [r, g, b].map(c => c.toString(16).padStart(2, '0')).join('').toUpperCase()
}
// ── Current color ────────────────────────────────────────────────────
const currentHex = computed(() => {
const [r, g, b] = hsvToRgb(hue.value, saturation.value, brightness.value)
return rgbToHex(r, g, b)
})
// ── Sync from prop ─────────────────────────────────────────────────
function syncFromHex(hex: string) {
const rgb = hexToRgb(hex)
if (!rgb) return
const [h, s, v] = rgbToHsv(...rgb)
hue.value = h
saturation.value = s
brightness.value = v
hexInput.value = hex.toUpperCase()
}
// Initialize from prop
syncFromHex(props.modelValue || '#D97706')
watch(() => props.modelValue, (val) => {
if (val && val.toUpperCase() !== currentHex.value) {
syncFromHex(val)
}
})
// ── Emit ────────────────────────────────────────────────────────────
function emitColor() {
hexInput.value = currentHex.value
emit('update:modelValue', currentHex.value)
}
// ── Hex Input ─────────────────────────────────────────────────────
function onHexInput(e: Event) {
const val = (e.target as HTMLInputElement).value
hexInput.value = val
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
syncFromHex(val)
emit('update:modelValue', val.toUpperCase())
}
}
// ── Preset Click ──────────────────────────────────────────────────
function selectPreset(color: string) {
syncFromHex(color)
emitColor()
}
// ── Gradient Canvas (Saturation/Brightness) ────────────────────────
function drawGradient() {
const canvas = gradientRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
// Base hue color
const [r, g, b] = hsvToRgb(hue.value, 100, 100)
// White to hue (horizontal)
const gradH = ctx.createLinearGradient(0, 0, w, 0)
gradH.addColorStop(0, '#FFFFFF')
gradH.addColorStop(1, `rgb(${r},${g},${b})`)
ctx.fillStyle = gradH
ctx.fillRect(0, 0, w, h)
// Black overlay (vertical)
const gradV = ctx.createLinearGradient(0, 0, 0, h)
gradV.addColorStop(0, 'rgba(0,0,0,0)')
gradV.addColorStop(1, 'rgba(0,0,0,1)')
ctx.fillStyle = gradV
ctx.fillRect(0, 0, w, h)
}
function drawHueStrip() {
const canvas = hueRef.value
if (!canvas) return
const ctx = canvas.getContext('2d')
if (!ctx) return
const w = canvas.width
const h = canvas.height
const grad = ctx.createLinearGradient(0, 0, w, 0)
for (let i = 0; i <= 360; i += 60) {
const [r, g, b] = hsvToRgb(i, 100, 100)
grad.addColorStop(i / 360, `rgb(${r},${g},${b})`)
}
ctx.fillStyle = grad
ctx.fillRect(0, 0, w, h)
}
// ── Gradient Pointer ────────────────────────────────────────────────
const gradientCursorX = computed(() => (saturation.value / 100) * 100)
const gradientCursorY = computed(() => ((100 - brightness.value) / 100) * 100)
const hueCursorX = computed(() => (hue.value / 360) * 100)
function handleGradientInteraction(e: MouseEvent | PointerEvent) {
const canvas = gradientRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
const y = Math.max(0, Math.min(1, (e.clientY - rect.top) / rect.height))
saturation.value = x * 100
brightness.value = (1 - y) * 100
emitColor()
}
function handleHueInteraction(e: MouseEvent | PointerEvent) {
const canvas = hueRef.value
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const x = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
hue.value = x * 360
nextTick(() => drawGradient())
emitColor()
}
function onGradientPointerDown(e: PointerEvent) {
draggingGradient.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleGradientInteraction(e)
}
function onGradientPointerMove(e: PointerEvent) {
if (!draggingGradient.value) return
handleGradientInteraction(e)
}
function onGradientPointerUp() {
draggingGradient.value = false
}
function onHuePointerDown(e: PointerEvent) {
draggingHue.value = true
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
handleHueInteraction(e)
}
function onHuePointerMove(e: PointerEvent) {
if (!draggingHue.value) return
handleHueInteraction(e)
}
function onHuePointerUp() {
draggingHue.value = false
}
// ── Positioning ─────────────────────────────────────────────────────
function updatePosition() {
if (!triggerRef.value) return
panelStyle.value = computeDropdownPosition(triggerRef.value, { minWidth: 260, estimatedHeight: 330, panelEl: panelRef.value })
}
// ── Open / Close ────────────────────────────────────────────────────
function toggle() {
if (isOpen.value) close()
else open()
}
function open() {
syncFromHex(props.modelValue || '#D97706')
isOpen.value = true
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)
window.removeEventListener('resize', onScrollOrResize)
}
function onClickOutside(e: MouseEvent) {
const target = e.target as Node
if (triggerRef.value?.contains(target) || panelRef.value?.contains(target)) return
close()
}
function onScrollOrResize() {
if (isOpen.value) updatePosition()
}
// Redraw gradient when hue changes
watch(hue, () => {
if (isOpen.value) nextTick(() => drawGradient())
})
onBeforeUnmount(() => {
document.removeEventListener('click', onClickOutside, true)
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>
<div class="relative">
<!-- Trigger button -->
<button
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
? {
borderColor: 'var(--color-accent)',
boxShadow: '0 0 0 2px var(--color-accent-muted)',
outline: 'none',
}
: {}
"
>
<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" aria-hidden="true" />
</button>
<!-- Color picker popover -->
<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 -->
<div class="px-3 pt-3 pb-2">
<div class="flex gap-2 flex-wrap">
<button
v-for="c in presets"
:key="c"
type="button"
@click="selectPreset(c)"
: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 }"
/>
</div>
</div>
<!-- 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"
width="260"
height="150"
class="w-full h-[150px] block rounded-lg"
/>
<!-- Cursor -->
<div
class="absolute w-4 h-4 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: gradientCursorX + '%',
top: gradientCursorY + '%',
backgroundColor: currentHex,
}"
/>
</div>
</div>
<!-- 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"
width="260"
height="14"
class="w-full h-3.5 block rounded-md"
/>
<!-- Hue cursor -->
<div
class="absolute top-1/2 w-3.5 h-3.5 rounded-full border-2 border-white shadow-[0_0_2px_rgba(0,0,0,0.6)] pointer-events-none -translate-x-1/2 -translate-y-1/2"
:style="{
left: hueCursorX + '%',
backgroundColor: rgbToHex(...hsvToRgb(hue, 100, 100)),
}"
/>
</div>
</div>
<!-- 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 }"
/>
<input
:value="hexInput"
@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"
/>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>