feat: snap floating add button to edge
This commit is contained in:
@@ -1,22 +1,45 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { Plus } from 'lucide-vue-next'
|
import { Plus } from 'lucide-vue-next'
|
||||||
import { clampFabPosition, isFabDrag } 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 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
|
||||||
|
let snappingTimer: number | undefined
|
||||||
|
|
||||||
const buttonStyle = computed(() => position.value
|
const buttonStyle = computed(() => position.value
|
||||||
? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' }
|
? { left: `${position.value.x}px`, top: `${position.value.y}px`, right: 'auto', bottom: 'auto' }
|
||||||
: {})
|
: {})
|
||||||
|
|
||||||
|
function clearSnappingTimer() {
|
||||||
|
if (snappingTimer) {
|
||||||
|
window.clearTimeout(snappingTimer)
|
||||||
|
snappingTimer = undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function startSnappingPulse() {
|
||||||
|
clearSnappingTimer()
|
||||||
|
snapping.value = true
|
||||||
|
snappingTimer = window.setTimeout(() => {
|
||||||
|
snapping.value = false
|
||||||
|
snappingTimer = undefined
|
||||||
|
}, 220)
|
||||||
|
}
|
||||||
|
function snapFabToCurrentViewport() {
|
||||||
|
if (!position.value || dragging.value) return
|
||||||
|
position.value = snapFabPosition(position.value.x, position.value.y, window.innerWidth, window.innerHeight, 56, 14, 84)
|
||||||
|
}
|
||||||
function startDrag(event: PointerEvent) {
|
function startDrag(event: PointerEvent) {
|
||||||
|
clearSnappingTimer()
|
||||||
|
snapping.value = false
|
||||||
const target = event.currentTarget as HTMLElement
|
const target = event.currentTarget as HTMLElement
|
||||||
const rect = target.getBoundingClientRect()
|
const rect = target.getBoundingClientRect()
|
||||||
|
position.value = clampFabPosition(rect.left, rect.top, window.innerWidth, window.innerHeight, 56, 14, 84)
|
||||||
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top }
|
pointer.value = { id: event.pointerId, startX: event.clientX, startY: event.clientY, originX: rect.left, originY: rect.top }
|
||||||
try { target.setPointerCapture?.(event.pointerId) } catch { /* pointer capture unavailable in synthetic events */ }
|
try { target.setPointerCapture?.(event.pointerId) } catch { /* pointer capture unavailable in synthetic events */ }
|
||||||
}
|
}
|
||||||
@@ -34,21 +57,30 @@ function finishDrag(event: PointerEvent) {
|
|||||||
if (!start || start.id !== event.pointerId) return
|
if (!start || start.id !== event.pointerId) return
|
||||||
const dragged = dragging.value || isFabDrag(event.clientX - start.startX, event.clientY - start.startY)
|
const dragged = dragging.value || isFabDrag(event.clientX - start.startX, event.clientY - start.startY)
|
||||||
pointer.value = null
|
pointer.value = null
|
||||||
dragging.value = false
|
|
||||||
if (dragged) {
|
if (dragged) {
|
||||||
|
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)
|
||||||
|
startSnappingPulse()
|
||||||
suppressClick = true
|
suppressClick = true
|
||||||
window.setTimeout(() => { suppressClick = false }, 180)
|
window.setTimeout(() => { suppressClick = false }, 180)
|
||||||
}
|
}
|
||||||
|
dragging.value = false
|
||||||
}
|
}
|
||||||
function activate(event: MouseEvent) {
|
function activate(event: MouseEvent) {
|
||||||
if (suppressClick) return
|
if (suppressClick) return
|
||||||
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect()
|
||||||
emit('activate', { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
|
emit('activate', { x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('resize', snapFabToCurrentViewport))
|
||||||
|
onUnmounted(() => {
|
||||||
|
window.removeEventListener('resize', snapFabToCurrentViewport)
|
||||||
|
clearSnappingTimer()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button class="unified-fab" :class="{ dragging }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
<button class="unified-fab" :class="{ dragging, snapping }" :style="buttonStyle" :aria-label="props.label" @pointerdown="startDrag" @pointermove="moveDrag" @pointerup="finishDrag" @pointercancel="finishDrag" @click="activate">
|
||||||
<Plus />
|
<Plus />
|
||||||
</button>
|
</button>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
import { archivePanelFlags, calendarModeLabel, changedHabitFields, clampFabPosition, countdownDayText, countdownKindLabel, dateKey, defaultView, formatArchivedAt, formatAuditAction, formatAuditEntity, formatHabitApiError, formatLocalShortDateTime, formatUserAgent, habitActionState, habitButtonNotice, habitButtonValue, habitWeek, invalidateHabitGridCache, isFabDrag, isHabitComplete, isHabitScheduledToday, isTaskView, nextHabitSwipeValue, nextTotalAfterLocalTaskAdd, nextTotalAfterLocalTaskRemoval, snapFabPosition, normalizeRequiredName, numericHabitInputValue, performHabitRestore, performTrashMutation, previousHabitSwipeValue, quickTaskFields, readCountdownCache, readHabitGridCache, readStoredBoolean, readStoredNavigation, shouldToggleRowSwipe, validateHabitForm, writeCountdownCache, writeHabitGridCache, writeStoredBoolean, writeStoredNavigation } from './mvp-utils'
|
||||||
|
|
||||||
describe('MVP view utilities', () => {
|
describe('MVP view utilities', () => {
|
||||||
it('formats a local date as YYYY-MM-DD', () => {
|
it('formats a local date as YYYY-MM-DD', () => {
|
||||||
@@ -181,6 +181,12 @@ describe('MVP view utilities', () => {
|
|||||||
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
|
expect(clampFabPosition(120, 300, 390, 844)).toEqual({ x: 120, y: 300 })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('snaps the add button to the nearest horizontal edge while preserving its safe vertical position', () => {
|
||||||
|
expect(snapFabPosition(40, 300, 390, 844)).toEqual({ x: 14, y: 300 })
|
||||||
|
expect(snapFabPosition(280, 300, 390, 844)).toEqual({ x: 324, y: 300 })
|
||||||
|
expect(snapFabPosition(180, 900, 390, 844)).toEqual({ x: 324, y: 718 })
|
||||||
|
})
|
||||||
|
|
||||||
it('validates safe habit input and emits only changed patch fields', () => {
|
it('validates safe habit input and emits only changed patch fields', () => {
|
||||||
expect(normalizeRequiredName(' 喝水 ')).toEqual({ value: '喝水', error: '' })
|
expect(normalizeRequiredName(' 喝水 ')).toEqual({ value: '喝水', error: '' })
|
||||||
expect(normalizeRequiredName(' \n ')).toEqual({ value: '', error: '名称不能为空,请输入至少一个可见字符。' })
|
expect(normalizeRequiredName(' \n ')).toEqual({ value: '', error: '名称不能为空,请输入至少一个可见字符。' })
|
||||||
|
|||||||
@@ -170,6 +170,14 @@ export function clampFabPosition(x: number, y: number, viewportWidth: number, vi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function snapFabPosition(x: number, y: number, viewportWidth: number, viewportHeight: number, size = 52, margin = 14, bottomReserved = 74) {
|
||||||
|
const clamped = clampFabPosition(x, y, viewportWidth, viewportHeight, size, margin, bottomReserved)
|
||||||
|
const left = margin
|
||||||
|
const right = Math.max(margin, viewportWidth - size - margin)
|
||||||
|
const midpoint = viewportWidth / 2
|
||||||
|
return { x: clamped.x + size / 2 < midpoint ? left : right, y: clamped.y }
|
||||||
|
}
|
||||||
|
|
||||||
export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
|
export function isFabDrag(deltaX: number, deltaY: number, threshold = 8) {
|
||||||
return Math.hypot(deltaX, deltaY) >= threshold
|
return Math.hypot(deltaX, deltaY) >= threshold
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ main{container-type:inline-size;min-width:0;padding:27px 34px 50px;overflow:auto
|
|||||||
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
@media(max-width:930px){.task-row,.habit-row,.countdown-row{min-height:62px;background:#fff;border:1px solid var(--line);border-radius:13px;box-shadow:none}.task-list,.habit-list,.countdown-timeline{display:grid;gap:8px}.task-row{padding:4px 7px}.task-row:hover,.task-row.selected{background:#fffaf5}.habit-row{padding:4px 7px}.countdown-row,.countdown-row:first-of-type{border:1px solid var(--line)}.countdown-row{grid-template-columns:minmax(0,1fr) 72px;padding:8px 9px;gap:8px}.task-main strong,.habit-name,.countdown-main>b{font-size:14px;font-weight:650}.meta,.countdown-main>small,.countdown-state small{font-size:11px;color:var(--muted)}.habit-progress{font-size:16px}.task-check{width:44px;flex-basis:44px}.countdown-icon{width:36px;height:36px;border-radius:10px}.countdown-state strong{font-size:24px}.countdown-group{gap:8px}.countdown-group>h3{padding-left:3px}.habit-detail-mask{padding:0}.habit-detail-sheet{width:100%;border-radius:22px 22px 0 0;padding:18px 16px calc(18px + env(safe-area-inset-bottom))}}
|
||||||
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
@media(max-width:800px){.settings-grid{grid-template-columns:1fr}.tool-card.wide{grid-column:auto}.habit-main{padding:10px 2px}.numeric-action input{width:62px}}
|
||||||
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
@media(max-width:390px){.task-compose-sheet{width:100%;max-width:100%;overflow-x:hidden}.task-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.task-compose-sheet input,.task-compose-sheet select,.task-compose-sheet button{min-height:44px}.task-compose-date-clear,.task-compose-time-remove{min-width:44px;min-height:44px}.task-compose-sheet .app-sheet__footer>button{min-height:44px}.habit-detail-sheet,.habit-compose-sheet{width:100%;max-width:100%}.habit-detail-sheet .app-sheet__header>button,.habit-compose-sheet .app-sheet__header>button{min-width:44px;min-height:44px}.habit-detail-sheet .app-sheet__footer>button,.habit-detail-sheet .app-sheet__danger>button,.habit-compose-sheet .app-sheet__footer>button{min-height:44px}}
|
||||||
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab svg{width:25px;height:25px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active{transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36)}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
.unified-fab{display:grid;place-items:center;position:fixed;z-index:60;right:20px;bottom:22px;width:56px;height:56px;border:0;border-radius:50%;background:var(--accent);color:#fff;box-shadow:0 8px 20px rgba(241,90,41,.28);transition:left .2s cubic-bezier(.2,.8,.3,1),top .2s cubic-bezier(.2,.8,.3,1),transform .16s ease,box-shadow .16s ease;touch-action:none;user-select:none}.unified-fab svg{width:25px;height:25px}.unified-fab:hover{transform:translateY(-2px);box-shadow:0 10px 24px rgba(241,90,41,.32)}.unified-fab:active{transform:scale(.96)}.unified-fab.dragging{transform:scale(1.06);box-shadow:0 12px 28px rgba(241,90,41,.36);transition:transform .16s ease,box-shadow .16s ease}.unified-fab.snapping{box-shadow:0 10px 24px rgba(241,90,41,.32)}.app-sheet-mask.app-sheet-mask{background:var(--sheet-scrim);overscroll-behavior:contain}.app-sheet{min-height:0;overflow:hidden;background:var(--paper)}.app-sheet__header{min-height:64px;flex:0 0 64px;position:sticky;z-index:3;top:0;background:var(--paper);border-bottom:1px solid var(--line);padding:0 18px}.app-sheet__header>div{min-width:0}.app-sheet__header h2,.app-sheet__header h3{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.app-sheet__header>button{width:44px;height:44px;flex:0 0 44px;display:grid;place-items:center;border:0;background:transparent;border-radius:10px}.app-sheet__body{min-height:0;overflow-y:auto;overscroll-behavior:contain;-webkit-overflow-scrolling:touch;display:grid;gap:14px;padding:16px 18px}.app-sheet__footer{position:sticky;bottom:0;z-index:3;margin:0;background:var(--paper);border-top:1px solid var(--line);padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end;gap:8px}.app-sheet__danger{border-top:1px solid #f1d4cd;background:#fff8f6;padding:12px 18px;padding-bottom:calc(16px + env(safe-area-inset-bottom));display:flex;justify-content:flex-end}.app-sheet--actions .app-sheet__body{gap:6px;padding:8px 16px calc(16px + env(safe-area-inset-bottom))}.app-sheet--actions .app-sheet__body>button{min-height:50px;width:100%;display:flex;align-items:center;gap:12px;border:0;background:#fff;padding:13px;border-radius:12px;text-align:left}
|
||||||
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
@media(max-width:930px){.app-sheet{width:100%;max-height:min(88dvh,760px);display:flex!important;flex-direction:column!important;overflow:hidden!important;border-radius:var(--sheet-radius) var(--sheet-radius) 0 0!important;padding:0!important}.app-sheet--detail,.app-sheet--create{max-width:none!important}.app-sheet-mask{padding:0!important;place-items:end center!important;align-items:flex-end!important}.app-sheet__header{display:flex!important;align-items:center!important;justify-content:space-between!important;width:100%}.app-sheet__body{width:100%;flex:1 1 auto}.app-sheet__body label{display:grid;gap:6px}.app-sheet__footer{width:100%;flex:0 0 auto}.app-sheet__footer .primary-small{min-width:124px}.app-sheet__danger{width:100%;flex:0 0 auto}.app-sheet__danger .danger-text{width:100%;min-height:48px;justify-content:center}}
|
||||||
|
|
||||||
.calendar-picker-layer{position:fixed;z-index:90;inset:0;pointer-events:none}.calendar-picker{position:fixed;width:328px;background:#fffdf8;border:1px solid #ded6ca;border-radius:18px;box-shadow:0 18px 46px rgba(56,40,24,.2);padding:14px;display:grid;gap:10px;pointer-events:auto;color:var(--text)}.calendar-picker__header{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:4px}.calendar-picker__header h3{margin:0;text-align:center;font-size:16px}.calendar-picker__header button,.calendar-picker__grid button{border:0;background:transparent;display:grid;place-items:center}.calendar-picker__header button{width:40px;height:40px;border-radius:10px}.calendar-picker__header svg{width:18px}.calendar-picker__close{display:none!important}.calendar-picker__weekdays,.calendar-picker__grid{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}.calendar-picker__weekdays span{text-align:center;color:var(--muted);font-size:11px;padding:3px 0}.calendar-picker__grid button{height:34px;border-radius:10px;color:#4f483f;font-size:13px}.calendar-picker__grid button:hover,.calendar-picker__grid button:focus-visible{background:#f6ede2;outline:2px solid transparent}.calendar-picker__grid button.outside{color:#b9b0a5}.calendar-picker__grid button.today{box-shadow:inset 0 0 0 1px var(--accent);color:#b7421e}.calendar-picker__grid button.selected{background:var(--accent);color:#fff}.calendar-picker__grid button.selected.today{box-shadow:inset 0 0 0 1px #fff}.calendar-picker__footer{display:grid;grid-template-columns:auto auto 1fr auto auto;align-items:center;gap:4px;border-top:1px solid #e8e0d5;padding-top:10px}.calendar-picker__footer button{min-height:36px;border:0;background:transparent;border-radius:9px;padding:0 9px;color:#6d6459;font-weight:650}.calendar-picker__footer button:hover{background:#f6ede2}.calendar-picker__footer .calendar-picker__done{background:var(--accent);color:#fff}.calendar-picker__footer .calendar-picker__done:hover{background:#df4e22}@media(max-width:600px){.calendar-picker-layer{pointer-events:auto;background:rgba(45,38,31,.3);display:flex;align-items:flex-end}.calendar-picker{position:relative!important;left:auto!important;top:auto!important;width:100%;height:min(520px,72dvh);border-width:1px 0 0;border-radius:24px 24px 0 0;padding:18px 18px calc(16px + env(safe-area-inset-bottom));grid-template-rows:auto auto 1fr auto}.calendar-picker__header{grid-template-columns:44px 1fr 44px 44px}.calendar-picker__close{display:grid!important}.calendar-picker__grid{align-content:stretch}.calendar-picker__grid button{height:auto;min-height:42px}.calendar-picker__footer button{min-height:44px}}
|
.calendar-picker-layer{position:fixed;z-index:90;inset:0;pointer-events:none}.calendar-picker{position:fixed;width:328px;background:#fffdf8;border:1px solid #ded6ca;border-radius:18px;box-shadow:0 18px 46px rgba(56,40,24,.2);padding:14px;display:grid;gap:10px;pointer-events:auto;color:var(--text)}.calendar-picker__header{display:grid;grid-template-columns:40px 1fr 40px;align-items:center;gap:4px}.calendar-picker__header h3{margin:0;text-align:center;font-size:16px}.calendar-picker__header button,.calendar-picker__grid button{border:0;background:transparent;display:grid;place-items:center}.calendar-picker__header button{width:40px;height:40px;border-radius:10px}.calendar-picker__header svg{width:18px}.calendar-picker__close{display:none!important}.calendar-picker__weekdays,.calendar-picker__grid{display:grid;grid-template-columns:repeat(7,1fr);gap:3px}.calendar-picker__weekdays span{text-align:center;color:var(--muted);font-size:11px;padding:3px 0}.calendar-picker__grid button{height:34px;border-radius:10px;color:#4f483f;font-size:13px}.calendar-picker__grid button:hover,.calendar-picker__grid button:focus-visible{background:#f6ede2;outline:2px solid transparent}.calendar-picker__grid button.outside{color:#b9b0a5}.calendar-picker__grid button.today{box-shadow:inset 0 0 0 1px var(--accent);color:#b7421e}.calendar-picker__grid button.selected{background:var(--accent);color:#fff}.calendar-picker__grid button.selected.today{box-shadow:inset 0 0 0 1px #fff}.calendar-picker__footer{display:grid;grid-template-columns:auto auto 1fr auto auto;align-items:center;gap:4px;border-top:1px solid #e8e0d5;padding-top:10px}.calendar-picker__footer button{min-height:36px;border:0;background:transparent;border-radius:9px;padding:0 9px;color:#6d6459;font-weight:650}.calendar-picker__footer button:hover{background:#f6ede2}.calendar-picker__footer .calendar-picker__done{background:var(--accent);color:#fff}.calendar-picker__footer .calendar-picker__done:hover{background:#df4e22}@media(max-width:600px){.calendar-picker-layer{pointer-events:auto;background:rgba(45,38,31,.3);display:flex;align-items:flex-end}.calendar-picker{position:relative!important;left:auto!important;top:auto!important;width:100%;height:min(520px,72dvh);border-width:1px 0 0;border-radius:24px 24px 0 0;padding:18px 18px calc(16px + env(safe-area-inset-bottom));grid-template-rows:auto auto 1fr auto}.calendar-picker__header{grid-template-columns:44px 1fr 44px 44px}.calendar-picker__close{display:grid!important}.calendar-picker__grid{align-content:stretch}.calendar-picker__grid button{height:auto;min-height:42px}.calendar-picker__footer button{min-height:44px}}
|
||||||
|
|||||||
@@ -1019,8 +1019,12 @@ describe('unified floating add interaction', () => {
|
|||||||
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('@pointermove="moveDrag"')
|
expect(floatingAdd).toContain('@pointermove="moveDrag"')
|
||||||
|
expect(floatingAdd).toContain("position.value = snapFabPosition(")
|
||||||
|
expect(floatingAdd).toContain('snapFabToCurrentViewport')
|
||||||
|
expect(floatingAdd).toContain("window.addEventListener('resize', snapFabToCurrentViewport)")
|
||||||
expect(floatingAdd).toContain("emit('activate',")
|
expect(floatingAdd).toContain("emit('activate',")
|
||||||
expect(css).toContain('.unified-fab.dragging')
|
expect(css).toContain('.unified-fab.dragging')
|
||||||
|
expect(css).toContain('.unified-fab.snapping')
|
||||||
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
|
expect(countdownPanel).toContain('<Transition name="countdown-compose">')
|
||||||
expect(css).toContain('.countdown-compose-enter-active')
|
expect(css).toContain('.countdown-compose-enter-active')
|
||||||
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))}')
|
||||||
|
|||||||
Reference in New Issue
Block a user