diff --git a/frontend/src/App.vue b/frontend/src/App.vue index 12483c4..8111e53 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -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(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(() => { -
+

{{ activeName }}

- +
+ + +