feat: turn floating add button into sleeping cat
This commit is contained in:
@@ -1,12 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
|
||||||
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
|
import { clampFabPosition, isFabDrag, snapFabPosition } from '../lib/mvp-utils'
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
|
const props = withDefaults(defineProps<{ label?: string }>(), { label: '添加' })
|
||||||
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
|
const emit = defineEmits<{ activate: [origin: { x: number; y: number }] }>()
|
||||||
const dragging = ref(false)
|
const dragging = ref(false)
|
||||||
const snapping = ref(false)
|
const snapping = ref(false)
|
||||||
|
const edge = ref<'left' | 'right'>('right')
|
||||||
const position = ref<{ x: number; y: number } | null>(null)
|
const position = ref<{ x: number; y: number } | null>(null)
|
||||||
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
|
const pointer = ref<{ id: number; startX: number; startY: number; originX: number; originY: number } | null>(null)
|
||||||
let suppressClick = false
|
let suppressClick = false
|
||||||
@@ -30,11 +30,17 @@ function startSnappingPulse() {
|
|||||||
snappingTimer = undefined
|
snappingTimer = undefined
|
||||||
}, 220)
|
}, 220)
|
||||||
}
|
}
|
||||||
|
function updateEdge() {
|
||||||
|
if (!position.value) return
|
||||||
|
edge.value = position.value.x + 28 <= window.innerWidth / 2 ? 'left' : 'right'
|
||||||
|
}
|
||||||
function snapFabToCurrentViewport() {
|
function snapFabToCurrentViewport() {
|
||||||
if (!position.value || dragging.value) return
|
if (!position.value || dragging.value) return
|
||||||
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, 84)
|
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, 84)
|
||||||
|
updateEdge()
|
||||||
}
|
}
|
||||||
function startDrag(event: PointerEvent) {
|
function startDrag(event: PointerEvent) {
|
||||||
|
if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return
|
||||||
clearSnappingTimer()
|
clearSnappingTimer()
|
||||||
snapping.value = false
|
snapping.value = false
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
@@ -60,6 +66,7 @@ function finishDrag(event: PointerEvent) {
|
|||||||
if (dragged) {
|
if (dragged) {
|
||||||
const current = position.value ?? clampFabPosition(start.originX, start.originY, window.innerWidth, window.innerHeight, 56, 14, 84)
|
const current = position.value ?? clampFabPosition(start.originX, start.originY, window.innerWidth, window.innerHeight, 56, 14, 84)
|
||||||
position.value = snapFabPosition(current.x, current.y, window.innerWidth, window.innerHeight, 56, 14, 84)
|
position.value = snapFabPosition(current.x, current.y, window.innerWidth, window.innerHeight, 56, 14, 84)
|
||||||
|
updateEdge()
|
||||||
startSnappingPulse()
|
startSnappingPulse()
|
||||||
suppressClick = true
|
suppressClick = true
|
||||||
window.setTimeout(() => { suppressClick = false }, 180)
|
window.setTimeout(() => { suppressClick = false }, 180)
|
||||||
@@ -80,7 +87,18 @@ onUnmounted(() => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="unified-fab" :class="{ dragging, snapping }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
<button class="unified-fab" :class="{ dragging, snapping, awake: dragging, sleeping: !dragging, 'edge-left': edge === 'left', 'edge-right': edge === 'right' }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||||
<Plus />
|
<svg class="fab-cat" viewBox="0 0 56 56" aria-hidden="true">
|
||||||
|
<g class="fab-cat-body">
|
||||||
|
<path class="fab-cat-tail" d="M41 38c7 2 8 9 3 11-4 2-8-1-7-4 1-2 4-2 5 0"/>
|
||||||
|
<path class="fab-cat-back" d="M13 41c1-10 7-17 17-17 8 0 14 5 15 13 1 6-3 10-10 10H19c-5 0-7-2-6-6Z"/>
|
||||||
|
<path class="fab-cat-head" d="M12 24 15 12l8 6c4-2 9-2 13 0l8-6 2 13c0 9-7 15-17 15S12 33 12 24Z"/>
|
||||||
|
<path class="fab-cat-ear" d="m17 18-1-4 4 3m20 1 2-4-5 3"/>
|
||||||
|
<path class="fab-cat-eye fab-cat-eye--sleeping" d="M19 27c2 2 4 2 6 0m8 0c2 2 4 2 6 0"/>
|
||||||
|
<g class="fab-cat-eye fab-cat-eye--awake"><circle cx="22" cy="27" r="2"/><circle cx="36" cy="27" r="2"/></g>
|
||||||
|
<path class="fab-cat-face" d="m27 31 2 1 2-1m-2 1v2m-13-2-6-1m6 4-6 2m32-5 6-1m-6 4 6 2"/>
|
||||||
|
</g>
|
||||||
|
<g class="fab-cat-sleep"><text x="39" y="13">z</text><text x="45" y="8">z</text></g>
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1034,6 +1034,7 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(app).toContain('<FloatingAddButton')
|
expect(app).toContain('<FloatingAddButton')
|
||||||
expect(floatingAdd).toContain('class="unified-fab"')
|
expect(floatingAdd).toContain('class="unified-fab"')
|
||||||
expect(floatingAdd).toContain('@pointerdown="startDrag"')
|
expect(floatingAdd).toContain('@pointerdown="startDrag"')
|
||||||
|
expect(floatingAdd).toContain("if (!event.isPrimary || (event.pointerType === 'mouse' && event.button !== 0)) return")
|
||||||
expect(floatingAdd).toContain('@pointermove="moveDrag"')
|
expect(floatingAdd).toContain('@pointermove="moveDrag"')
|
||||||
expect(floatingAdd).toContain("position.value = snapFabPosition(")
|
expect(floatingAdd).toContain("position.value = snapFabPosition(")
|
||||||
expect(floatingAdd).toContain('snapFabToCurrentViewport')
|
expect(floatingAdd).toContain('snapFabToCurrentViewport')
|
||||||
@@ -1046,13 +1047,19 @@ describe('unified floating add interaction', () => {
|
|||||||
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
expect(css).toContain('@media(max-width:930px){.unified-fab{bottom:calc(82px + env(safe-area-inset-bottom))}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the shared FAB visually simple', () => {
|
it('renders a sleeping cream-orange cat that wakes while dragging and faces inward at an edge', () => {
|
||||||
expect(floatingAdd).not.toContain('unified-fab-aura')
|
expect(floatingAdd).toContain('class="fab-cat"')
|
||||||
expect(floatingAdd).not.toContain('unified-fab-core')
|
expect(floatingAdd).toContain('class="fab-cat-eye fab-cat-eye--sleeping"')
|
||||||
expect(css).not.toContain('.unified-fab:before')
|
expect(floatingAdd).toContain('class="fab-cat-eye fab-cat-eye--awake"')
|
||||||
expect(css).not.toContain('@keyframes fab-aura')
|
expect(floatingAdd).toContain("const edge = ref<'left' | 'right'>('right')")
|
||||||
expect(css).not.toContain('@keyframes fab-pulse')
|
expect(floatingAdd).toContain("'edge-left': edge === 'left'")
|
||||||
expect(css).toMatch(/\.unified-fab\{[^}]*background:var\(--accent\)/)
|
expect(floatingAdd).toContain(':class="{ dragging, snapping, awake: dragging, sleeping: !dragging')
|
||||||
|
expect(css).toContain('.unified-fab .fab-cat{')
|
||||||
|
expect(css).toContain('.unified-fab.sleeping .fab-cat-body{transform-origin:29px 40px;animation:fab-cat-breathe')
|
||||||
|
expect(css).toContain('.unified-fab.awake .fab-cat-eye--awake{opacity:1}')
|
||||||
|
expect(css).toContain('.unified-fab.edge-left .fab-cat{transform:scaleX(-1)}')
|
||||||
|
expect(css).toContain('@keyframes fab-cat-breathe')
|
||||||
|
expect(css).toContain('@media(prefers-reduced-motion:reduce){.unified-fab.sleeping .fab-cat-body{animation:none}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the countdown close button but removes its orange circular background', () => {
|
it('keeps the countdown close button but removes its orange circular background', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user