feat: add transitions and micro-interactions across all views
- Page transitions with slide-up/fade on route changes (App.vue) - NavRail sliding active indicator with spring-like easing - List enter/leave/move animations on Entries, Projects, Clients, Timer - Modal enter/leave transitions with scale+fade on all dialogs - Dropdown transitions with overshoot on all select/picker components - Button feedback (scale on hover/active), card hover lift effects - Timer pulse on start, glow on stop, floating empty state icons - Content fade-in on Dashboard, Reports, Calendar, Timesheet - Tag chip enter/leave animations in AppTagInput - Progress bar smooth width transitions - Implementation plan document
This commit is contained in:
423
src/components/AppColorPicker.vue
Normal file
423
src/components/AppColorPicker.vue
Normal file
@@ -0,0 +1,423 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onBeforeUnmount, nextTick } from 'vue'
|
||||
import { Pipette } from 'lucide-vue-next'
|
||||
import { computeDropdownPosition } from '../utils/dropdown'
|
||||
|
||||
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 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 })
|
||||
}
|
||||
|
||||
// ── Open / Close ────────────────────────────────────────────────────
|
||||
|
||||
function toggle() {
|
||||
if (isOpen.value) close()
|
||||
else open()
|
||||
}
|
||||
|
||||
function open() {
|
||||
syncFromHex(props.modelValue || '#D97706')
|
||||
isOpen.value = true
|
||||
updatePosition()
|
||||
|
||||
nextTick(() => {
|
||||
drawGradient()
|
||||
drawHueStrip()
|
||||
document.addEventListener('click', onClickOutside, true)
|
||||
document.addEventListener('scroll', onScrollOrResize, true)
|
||||
window.addEventListener('resize', onScrollOrResize)
|
||||
})
|
||||
}
|
||||
|
||||
function close() {
|
||||
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)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<!-- Trigger button -->
|
||||
<button
|
||||
ref="triggerRef"
|
||||
type="button"
|
||||
@click="toggle"
|
||||
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
|
||||
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" />
|
||||
</button>
|
||||
|
||||
<!-- Color picker popover -->
|
||||
<Teleport to="body">
|
||||
<Transition name="dropdown">
|
||||
<div
|
||||
v-if="isOpen"
|
||||
ref="panelRef"
|
||||
:style="panelStyle"
|
||||
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)"
|
||||
class="w-6 h-6 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
|
||||
class="relative rounded-lg overflow-hidden cursor-crosshair"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onGradientPointerDown"
|
||||
@pointermove="onGradientPointerMove"
|
||||
@pointerup="onGradientPointerUp"
|
||||
>
|
||||
<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
|
||||
class="relative rounded-md overflow-hidden cursor-pointer"
|
||||
style="touch-action: none;"
|
||||
@pointerdown="onHuePointerDown"
|
||||
@pointermove="onHuePointerMove"
|
||||
@pointerup="onHuePointerUp"
|
||||
>
|
||||
<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
|
||||
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"
|
||||
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>
|
||||
Reference in New Issue
Block a user