feat: improve task search reveal interaction
This commit is contained in:
+74
-2
@@ -10,6 +10,7 @@ import { beginLatestRequest, createMutationReconciler, formatApiErrorDetail, isL
|
||||
import { csrfHeader } from './lib/csrf'
|
||||
import { createCompletionPulse } from './lib/completion-motion'
|
||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||
import { clampSearchPullDistance, isAtSearchPullOrigin, isSearchShortcut, shouldHideSearchAfterSwipe, shouldRevealSearchAfterPull } from './lib/mobile-search'
|
||||
import { nextDialogFocusIndex } from './lib/list-purge'
|
||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||
import MvpPanel from './MvpPanel.vue'
|
||||
@@ -66,6 +67,15 @@ let listHandleLongPressTimer: number | undefined
|
||||
let listHandlePending: { id: string; pointer: ListDragPointer } | undefined
|
||||
let suppressListClickId = ''
|
||||
const query = ref('')
|
||||
const searchInput = ref<HTMLInputElement | null>(null)
|
||||
const mobileSearchOpen = ref(false)
|
||||
const searchPullDistance = ref(0)
|
||||
let searchTouchStartY: number | null = null
|
||||
const taskSearchAvailable = computed(() => ['tasks', 'today', 'upcoming', 'trash'].includes(activeView.value))
|
||||
const searchRevealStyle = computed(() => ({
|
||||
'--search-pull': `${searchPullDistance.value}px`,
|
||||
'--search-pull-opacity': String(Math.min(1, searchPullDistance.value / 56)),
|
||||
}))
|
||||
const error = ref('')
|
||||
const notice = ref('')
|
||||
const loading = ref(false)
|
||||
@@ -568,6 +578,8 @@ async function loadTrash() {
|
||||
async function switchView(view: View, listId?: string) {
|
||||
taskMutationNavigation.value += 1
|
||||
activeView.value = view
|
||||
if (!query.value) mobileSearchOpen.value = false
|
||||
searchPullDistance.value = 0
|
||||
if (view !== 'trash') beginLatestRequest('trash')
|
||||
if (!isTaskView(view)) {
|
||||
beginLatestRequest('tasks')
|
||||
@@ -1169,9 +1181,65 @@ function nextPage() {
|
||||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||
}
|
||||
|
||||
function isMobileSearchLayout() {
|
||||
return window.matchMedia('(max-width: 930px)').matches
|
||||
}
|
||||
function openTaskSearch() {
|
||||
if (!taskSearchAvailable.value) return
|
||||
if (isMobileSearchLayout()) mobileSearchOpen.value = true
|
||||
searchPullDistance.value = 0
|
||||
void nextTick(() => searchInput.value?.focus())
|
||||
}
|
||||
function startSearchPull(event: TouchEvent) {
|
||||
if (!taskSearchAvailable.value || !isMobileSearchLayout()) return
|
||||
const target = event.target as Element | null
|
||||
if (target?.closest('input, textarea, select, button, .task-row, .habit-row, .countdown-row')) return
|
||||
const main = event.currentTarget as HTMLElement
|
||||
const shell = main.closest('.shell') as HTMLElement | null
|
||||
if (!isAtSearchPullOrigin(main.scrollTop, shell?.scrollTop ?? 0, window.scrollY) || event.touches.length !== 1) return
|
||||
searchTouchStartY = event.touches[0].clientY
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function moveSearchPull(event: TouchEvent) {
|
||||
if (searchTouchStartY === null || event.touches.length !== 1) return
|
||||
const deltaY = event.touches[0].clientY - searchTouchStartY
|
||||
if (mobileSearchOpen.value) {
|
||||
searchPullDistance.value = Math.min(0, deltaY)
|
||||
if (deltaY < 0 && !query.value.trim()) event.preventDefault()
|
||||
return
|
||||
}
|
||||
searchPullDistance.value = clampSearchPullDistance(deltaY)
|
||||
if (deltaY > 0) event.preventDefault()
|
||||
}
|
||||
function finishSearchPull(event: TouchEvent) {
|
||||
if (searchTouchStartY === null) return
|
||||
const endY = event.changedTouches[0]?.clientY ?? searchTouchStartY
|
||||
const deltaY = endY - searchTouchStartY
|
||||
searchTouchStartY = null
|
||||
if (!mobileSearchOpen.value && shouldRevealSearchAfterPull(deltaY)) openTaskSearch()
|
||||
else if (mobileSearchOpen.value && shouldHideSearchAfterSwipe(deltaY, query.value)) {
|
||||
mobileSearchOpen.value = false
|
||||
searchInput.value?.blur()
|
||||
}
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function cancelSearchPull() {
|
||||
searchTouchStartY = null
|
||||
searchPullDistance.value = 0
|
||||
}
|
||||
function handleTaskSearchShortcut(event: KeyboardEvent) {
|
||||
if (!isSearchShortcut(event) || !taskSearchAvailable.value) return
|
||||
event.preventDefault()
|
||||
openTaskSearch()
|
||||
}
|
||||
function handleSearchBlur() {
|
||||
if (!query.value.trim()) mobileSearchOpen.value = false
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||
document.addEventListener('keydown', handleArchivedListEscape)
|
||||
document.addEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.addEventListener('resize', handleArchivedListViewportChange)
|
||||
document.addEventListener('scroll', handleArchivedListViewportChange, true)
|
||||
void bootstrap()
|
||||
@@ -1179,6 +1247,7 @@ onMounted(() => {
|
||||
onUnmounted(() => {
|
||||
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||
document.removeEventListener('keydown', handleArchivedListEscape)
|
||||
document.removeEventListener('keydown', handleTaskSearchShortcut)
|
||||
window.removeEventListener('resize', handleArchivedListViewportChange)
|
||||
document.removeEventListener('scroll', handleArchivedListViewportChange, true)
|
||||
})
|
||||
@@ -1227,12 +1296,15 @@ onUnmounted(() => {
|
||||
<div v-if="sidebarAction" class="sidebar-action-mask app-sheet-mask" @click.self="closeSidebarAction"><section class="sidebar-action-sheet app-sheet app-sheet--actions" role="dialog" aria-modal="true" :aria-label="`${sidebarAction.item.name}操作`"><header class="app-sheet__header"><b>{{sidebarAction.item.name}}</b><button class="icon" aria-label="关闭清单操作" @click="closeSidebarAction"><X/></button></header><div class="app-sheet__body"><button @click="renameEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><Pencil/>重命名</button><button v-if="sidebarAction.kind==='folders'" @click="createList(sidebarAction.item.id);closeSidebarAction()"><Plus/>新建清单</button><template v-if="sidebarAction.kind==='lists'"><button aria-label="上移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'up')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'up')">上移</button><button aria-label="下移清单" :disabled="!canMoveListWithinScope(sidebarAction.item as TaskList, 'down')" @click="moveListWithinScope(sidebarAction.item as TaskList, 'down')">下移</button><button aria-label="移动到文件夹" aria-haspopup="menu" :aria-expanded="listMoveMenuOpen" @click="openListMoveMenu"><Folder/>移动到文件夹</button><div v-if="listMoveMenuOpen" class="list-move-menu" role="menu" aria-label="选择目标文件夹"><button v-if="(sidebarAction.item as TaskList).folder_id" role="menuitem" @click="moveListFromMenu(null)">移出文件夹</button><button v-for="folder in folders" :key="folder.id" role="menuitem" :disabled="(sidebarAction.item as TaskList).folder_id===folder.id" @click="moveListFromMenu(folder.id)">{{folder.name}}</button></div></template><button class="danger" @click="deleteEntity(sidebarAction.kind,sidebarAction.item);closeSidebarAction()"><component :is="sidebarAction.kind==='folders' ? Trash2 : ArchiveRestore"/>{{sidebarAction.kind==='folders'?'删除文件夹':'归档清单'}}</button></div></section></div>
|
||||
</aside>
|
||||
|
||||
<main>
|
||||
<main @touchstart="startSearchPull" @touchmove="moveSearchPull" @touchend="finishSearchPull" @touchcancel="cancelSearchPull">
|
||||
<header class="topbar">
|
||||
<button class="icon" :aria-label="(sidebarCollapsed ? '展开' : '收起') + '菜单'" :aria-expanded="sidebarCollapsed ? 'false' : 'true'" @click="toggleSidebar"><Menu /></button>
|
||||
<div class="topbar-title"><h1 :title="activeName">{{ activeName }}</h1></div>
|
||||
<CompletedFilterPill v-if="['today', 'tasks', 'upcoming', 'habits'].includes(activeView)" v-model="showCompleted" class="topbar-filter" />
|
||||
<label v-if="['tasks','today','upcoming','trash'].includes(activeView)" class="search"><Search/><input v-model="query" placeholder="搜索任务…" aria-label="搜索任务"><kbd>⌘ K</kbd></label>
|
||||
<div v-if="taskSearchAvailable" class="search-reveal" :class="{'mobile-search-open':mobileSearchOpen || Boolean(query),'mobile-search-pulling':searchPullDistance>0}" :style="searchRevealStyle">
|
||||
<span class="search-pull-hint" aria-hidden="true">{{searchPullDistance >= 56 ? '松开搜索' : '下拉搜索'}}</span>
|
||||
<label class="search"><Search/><input ref="searchInput" v-model="query" placeholder="搜索任务…" aria-label="搜索任务" @blur="handleSearchBlur"><kbd>⌘ K</kbd></label>
|
||||
</div>
|
||||
</header>
|
||||
<template v-if="['habits','settings'].includes(activeView)">
|
||||
<MvpPanel ref="habitComposer" :key="activeView" :view="activeView as 'habits'|'settings'" :show-completed="showCompleted" @changed="refreshAll" @notice="toast" />
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
clampSearchPullDistance,
|
||||
isSearchShortcut,
|
||||
isAtSearchPullOrigin,
|
||||
shouldHideSearchAfterSwipe,
|
||||
shouldRevealSearchAfterPull,
|
||||
} from './mobile-search'
|
||||
|
||||
describe('mobile task search gestures', () => {
|
||||
it('clamps downward pull distance without treating upward motion as pull', () => {
|
||||
expect(clampSearchPullDistance(-20)).toBe(0)
|
||||
expect(clampSearchPullDistance(24)).toBe(24)
|
||||
expect(clampSearchPullDistance(100)).toBe(72)
|
||||
})
|
||||
|
||||
it('reveals only after crossing the pull threshold', () => {
|
||||
expect(shouldRevealSearchAfterPull(55)).toBe(false)
|
||||
expect(shouldRevealSearchAfterPull(56)).toBe(true)
|
||||
})
|
||||
|
||||
it('requires every relevant scroll container to be at the top', () => {
|
||||
expect(isAtSearchPullOrigin(0, 0, 0)).toBe(true)
|
||||
expect(isAtSearchPullOrigin(0, 12, 0)).toBe(false)
|
||||
})
|
||||
|
||||
it('hides only after an upward swipe when the query is empty', () => {
|
||||
expect(shouldHideSearchAfterSwipe(-36, '')).toBe(true)
|
||||
expect(shouldHideSearchAfterSwipe(-35, '')).toBe(false)
|
||||
expect(shouldHideSearchAfterSwipe(-80, 'dodo')).toBe(false)
|
||||
})
|
||||
|
||||
it('recognizes command/control K without hijacking plain typing', () => {
|
||||
expect(isSearchShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true)
|
||||
expect(isSearchShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true)
|
||||
expect(isSearchShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,23 @@
|
||||
export const SEARCH_PULL_THRESHOLD = 56
|
||||
export const SEARCH_PULL_LIMIT = 72
|
||||
export const SEARCH_HIDE_SWIPE_THRESHOLD = 36
|
||||
|
||||
export function clampSearchPullDistance(distance: number) {
|
||||
return Math.min(SEARCH_PULL_LIMIT, Math.max(0, distance))
|
||||
}
|
||||
|
||||
export function shouldRevealSearchAfterPull(distance: number) {
|
||||
return distance >= SEARCH_PULL_THRESHOLD
|
||||
}
|
||||
|
||||
export function isAtSearchPullOrigin(...scrollPositions: number[]) {
|
||||
return scrollPositions.every((position) => position <= 0)
|
||||
}
|
||||
|
||||
export function shouldHideSearchAfterSwipe(deltaY: number, query: string) {
|
||||
return !query.trim() && deltaY <= -SEARCH_HIDE_SWIPE_THRESHOLD
|
||||
}
|
||||
|
||||
export function isSearchShortcut(event: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey'>) {
|
||||
return (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k'
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
@@ -620,19 +620,26 @@ describe('task and habit row decoration', () => {
|
||||
expect(todayHabits).not.toContain('显示已完成')
|
||||
})
|
||||
|
||||
it('keeps the pill at the topbar right and wraps only search when space is narrow', () => {
|
||||
it('uses a full-width search row and hides it behind mobile pull-to-reveal', () => {
|
||||
expect(css).toContain('main{container-type:inline-size;')
|
||||
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter";')
|
||||
expect(css).toContain('.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) minmax(180px,260px) auto;grid-template-areas:"menu title search filter"}')
|
||||
expect(css).toContain('.topbar{display:grid;grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" "search search search";')
|
||||
expect(css).toContain('.search-reveal{grid-area:search;width:100%;min-width:0}')
|
||||
expect(css).toContain('.topbar .search{width:100%;margin:0}')
|
||||
expect(css).toContain('.topbar-title{grid-area:title;min-width:0;width:100%}')
|
||||
expect(css).toContain('.topbar h1{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;')
|
||||
expect(css).toContain('.topbar-filter{grid-area:filter;justify-self:end;')
|
||||
expect(app.indexOf('class="topbar-filter"')).toBeLessThan(app.indexOf('class="search"'))
|
||||
expect(css).toContain('@container(max-width:640px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;')
|
||||
expect(css).toContain('.topbar>.icon{grid-area:menu}.topbar-title{grid-area:title}.topbar-filter{grid-area:filter}.topbar .search{grid-area:search;grid-row:2;justify-self:end;width:min(260px,100%);')
|
||||
expect(css).toContain('@media(max-width:930px){.topbar:has(.search):has(.topbar-filter){grid-template-columns:44px minmax(0,1fr) auto;grid-template-areas:"menu title filter" ". search search";grid-template-rows:auto auto;')
|
||||
expect(app.indexOf('class="topbar-filter"')).toBeLessThan(app.indexOf('class="search-reveal"'))
|
||||
expect(app).toContain('@touchstart="startSearchPull"')
|
||||
expect(app).toContain('@touchmove="moveSearchPull"')
|
||||
expect(app).toContain('@touchend="finishSearchPull"')
|
||||
expect(app).toContain('ref="searchInput"')
|
||||
expect(app).toContain('class="search-pull-hint"')
|
||||
expect(css).toContain('.search-reveal{max-height:0;opacity:0;overflow:hidden;visibility:hidden;pointer-events:none;')
|
||||
expect(css).toContain('.search-reveal.mobile-search-open{max-height:52px;opacity:1;visibility:visible;pointer-events:auto;transform:none;overflow:visible}')
|
||||
expect(css).toContain('.search-reveal.mobile-search-pulling{max-height:var(--search-pull);')
|
||||
const mobileLayout = css.slice(css.indexOf('@media(max-width:930px){.shell'))
|
||||
expect(mobileLayout).toContain('.completed-filter-pill{min-width:138px;height:44px')
|
||||
expect(css).not.toContain('width:min(260px,100%)')
|
||||
expect(css).not.toContain('.today-completed-toolbar{margin-top:8px}')
|
||||
expect(css).not.toContain('.habit-toolbar{')
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user