Re-order lists #27
@@ -9,6 +9,7 @@ import type {
|
||||
SetListItemTitlePayload,
|
||||
AddUserToListPayload,
|
||||
RemoveUserFromListPayload,
|
||||
OrderListsPayload,
|
||||
} from '@/types/list'
|
||||
|
||||
export async function getListsApi(): Promise<List[]> {
|
||||
@@ -77,6 +78,13 @@ export async function removeUserFromListApi(payload: RemoveUserFromListPayload):
|
||||
}
|
||||
}
|
||||
|
||||
export async function orderListsApi(payload: OrderListsPayload): Promise<void> {
|
||||
const response = await apiClient.post('/lists/order', payload)
|
||||
if (!response.ok) {
|
||||
throw new Error(await extractErrorMessage(response, 'Failed to reorder lists'))
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteListApi(listId: string): Promise<void> {
|
||||
const response = await apiClient.delete(`/lists/${listId}`)
|
||||
if (!response.ok) {
|
||||
|
||||
@@ -5,10 +5,11 @@ import type { LocalList } from '@/database/db'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
import { useDismissableMenu } from '@/composables/useDismissableMenu'
|
||||
|
||||
const props = defineProps<{ list: LocalList }>()
|
||||
const props = defineProps<{ list: LocalList; dragging?: boolean }>()
|
||||
const emit = defineEmits<{
|
||||
share: [list: LocalList]
|
||||
delete: [list: LocalList]
|
||||
'handle-pointerdown': [event: PointerEvent]
|
||||
}>()
|
||||
|
||||
const listsStore = useListsStore()
|
||||
@@ -49,7 +50,24 @@ function handleDelete(event: Event) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="list-card card">
|
||||
<div class="list-card card" :class="{ 'is-dragging': dragging }">
|
||||
<button
|
||||
type="button"
|
||||
class="grab-handle"
|
||||
aria-label="Reorder list"
|
||||
title="Drag to reorder"
|
||||
@pointerdown="emit('handle-pointerdown', $event)"
|
||||
>
|
||||
<svg class="grab-icon" viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<circle cx="9" cy="6" r="1.6" />
|
||||
<circle cx="15" cy="6" r="1.6" />
|
||||
<circle cx="9" cy="12" r="1.6" />
|
||||
<circle cx="15" cy="12" r="1.6" />
|
||||
<circle cx="9" cy="18" r="1.6" />
|
||||
<circle cx="15" cy="18" r="1.6" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<RouterLink :to="`/lists/${list.id}`" class="list-card-link">
|
||||
<div class="list-card-main">
|
||||
<h3>{{ list.name }}</h3>
|
||||
@@ -144,6 +162,48 @@ function handleDelete(event: Event) {
|
||||
border-color: var(--c-border-hover);
|
||||
}
|
||||
|
||||
.list-card.is-dragging {
|
||||
border-color: var(--c-accent-strong);
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.grab-handle {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 44px;
|
||||
margin: -0.5rem -0.2rem -0.5rem -0.5rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--c-text-soft);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
transition:
|
||||
background-color 0.15s ease-in-out,
|
||||
color 0.15s ease-in-out;
|
||||
}
|
||||
|
||||
.grab-handle:hover {
|
||||
background-color: var(--c-bg-mute);
|
||||
color: var(--c-heading);
|
||||
}
|
||||
|
||||
.list-card.is-dragging .grab-handle {
|
||||
cursor: grabbing;
|
||||
color: var(--c-accent-strong);
|
||||
}
|
||||
|
||||
.grab-icon {
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.list-card-link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
import { ref, type Ref } from 'vue'
|
||||
import type { LocalList } from '@/database/db'
|
||||
|
||||
// Matches the CSS transition duration on `.list-row` (see ListsView.vue) -
|
||||
// how long the dragged row's "snap into place" animation takes after drop,
|
||||
// before handing the final order back to the caller.
|
||||
const SETTLE_MS = 220
|
||||
|
||||
interface SlotRect {
|
||||
top: number
|
||||
height: number
|
||||
}
|
||||
|
||||
// Drag-to-reorder for the lists overview page. Deliberately built on raw
|
||||
// Pointer Events rather than HTML5 drag-and-drop, which has no usable touch
|
||||
// story - this needs to work as a one-finger drag on a phone first.
|
||||
//
|
||||
// The dragged row stays in the v-for (rather than becoming a floating
|
||||
// clone): its transform is computed each pointermove as the delta between
|
||||
// where the pointer says it should be and where it currently sits in the
|
||||
// (already reordered) array. Because `items` gets live-spliced as the
|
||||
// pointer crosses sibling midpoints, that natural position jumps in whole
|
||||
// slot-steps while the transform absorbs the remainder - the row tracks the
|
||||
// finger with no lag and no double-counted offset. Every other row's shift
|
||||
// is left entirely to <TransitionGroup>'s built-in FLIP move animation.
|
||||
export function useListDragReorder(
|
||||
items: Ref<LocalList[]>,
|
||||
onReorder: (orderedIds: string[]) => void,
|
||||
) {
|
||||
const draggingId = ref<string | null>(null)
|
||||
const isPointerActive = ref(false)
|
||||
const dragOffsetPx = ref(0)
|
||||
|
||||
const itemEls = new Map<string, HTMLElement>()
|
||||
let slots: SlotRect[] = []
|
||||
let startClientY = 0
|
||||
let startTop = 0
|
||||
let startHeight = 0
|
||||
let activePointerId: number | null = null
|
||||
let settleTimeout: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
function setItemRef(id: string, el: Element | null) {
|
||||
if (el) itemEls.set(id, el as HTMLElement)
|
||||
else itemEls.delete(id)
|
||||
}
|
||||
|
||||
function onPointerDown(id: string, event: PointerEvent) {
|
||||
if (event.pointerType === 'mouse' && event.button !== 0) return
|
||||
const el = itemEls.get(id)
|
||||
if (!el) return
|
||||
|
||||
if (settleTimeout !== null) {
|
||||
clearTimeout(settleTimeout)
|
||||
settleTimeout = null
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
activePointerId = event.pointerId
|
||||
|
||||
// Fixed physical slot positions for this drag, captured once up front.
|
||||
// Reordering `items` mid-drag doesn't shift these - slot i always means
|
||||
// "the i-th row's on-screen position", independent of which id currently
|
||||
// occupies it - so the target-slot math below never has to re-measure a
|
||||
// layout our own reordering just changed.
|
||||
slots = items.value.map((item) => {
|
||||
const itemEl = itemEls.get(item.id)
|
||||
const rect = itemEl?.getBoundingClientRect()
|
||||
return { top: rect?.top ?? 0, height: rect?.height ?? 0 }
|
||||
})
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
startTop = rect.top
|
||||
startHeight = rect.height
|
||||
startClientY = event.clientY
|
||||
draggingId.value = id
|
||||
isPointerActive.value = true
|
||||
dragOffsetPx.value = 0
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove)
|
||||
window.addEventListener('pointerup', onPointerUp)
|
||||
window.addEventListener('pointercancel', onPointerUp)
|
||||
}
|
||||
|
||||
function onPointerMove(event: PointerEvent) {
|
||||
if (!isPointerActive.value || event.pointerId !== activePointerId) return
|
||||
event.preventDefault()
|
||||
|
||||
const desiredTop = startTop + (event.clientY - startClientY)
|
||||
const draggedCenter = desiredTop + startHeight / 2
|
||||
|
||||
let targetIndex = slots.findIndex((slot) => draggedCenter < slot.top + slot.height / 2)
|
||||
if (targetIndex === -1) targetIndex = slots.length - 1
|
||||
|
||||
const currentIndex = items.value.findIndex((item) => item.id === draggingId.value)
|
||||
if (currentIndex !== -1 && targetIndex !== currentIndex) {
|
||||
const reordered = [...items.value]
|
||||
const moved = reordered.splice(currentIndex, 1)[0]
|
||||
if (moved) {
|
||||
reordered.splice(targetIndex, 0, moved)
|
||||
items.value = reordered
|
||||
}
|
||||
}
|
||||
|
||||
const settledIndex = items.value.findIndex((item) => item.id === draggingId.value)
|
||||
dragOffsetPx.value = desiredTop - (slots[settledIndex]?.top ?? startTop)
|
||||
}
|
||||
|
||||
function onPointerUp(event: PointerEvent) {
|
||||
if (!isPointerActive.value || event.pointerId !== activePointerId) return
|
||||
window.removeEventListener('pointermove', onPointerMove)
|
||||
window.removeEventListener('pointerup', onPointerUp)
|
||||
window.removeEventListener('pointercancel', onPointerUp)
|
||||
activePointerId = null
|
||||
isPointerActive.value = false
|
||||
|
||||
// Let the row's CSS transition animate the residual offset back to 0
|
||||
// (see ListsView.vue: transitions are suppressed only while
|
||||
// isPointerActive), then hand back the final order.
|
||||
dragOffsetPx.value = 0
|
||||
const finalIds = items.value.map((item) => item.id)
|
||||
settleTimeout = setTimeout(() => {
|
||||
settleTimeout = null
|
||||
draggingId.value = null
|
||||
onReorder(finalIds)
|
||||
}, SETTLE_MS)
|
||||
}
|
||||
|
||||
return { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown }
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
CreateListItemPayload,
|
||||
SetListItemCompletedPayload,
|
||||
SetListItemTitlePayload,
|
||||
OrderListsPayload,
|
||||
} from '@/types/list'
|
||||
|
||||
export interface LocalList extends List {
|
||||
@@ -40,6 +41,7 @@ type SyncOperationPayloads = {
|
||||
setListItemCompleted: SetListItemCompletedPayload
|
||||
deleteList: DeleteListPayload
|
||||
deleteListItem: DeleteListItemPayload
|
||||
orderLists: OrderListsPayload
|
||||
}
|
||||
|
||||
export type SyncOperationType = keyof SyncOperationPayloads
|
||||
|
||||
@@ -115,6 +115,7 @@ const listsApiMocks = vi.hoisted(() => ({
|
||||
removeUserFromListApi: vi.fn<() => Promise<unknown>>(),
|
||||
deleteListApi: vi.fn<() => Promise<unknown>>(),
|
||||
deleteListItemApi: vi.fn<() => Promise<unknown>>(),
|
||||
orderListsApi: vi.fn<() => Promise<unknown>>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/lists', () => listsApiMocks)
|
||||
@@ -531,6 +532,103 @@ describe('useListsStore', () => {
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('sorts lists by position, breaking ties (e.g. two lists both at position 0) by newest first', async () => {
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-b',
|
||||
name: 'B',
|
||||
position: 1,
|
||||
created_at: '2024-01-01T00:00:00.000Z',
|
||||
pendingSync: false,
|
||||
})
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-a',
|
||||
name: 'A',
|
||||
position: 0,
|
||||
created_at: '2024-01-02T00:00:00.000Z',
|
||||
pendingSync: false,
|
||||
})
|
||||
// Newly created lists always come back from the server at position 0 (so
|
||||
// a new list is always on top), so a not-yet-synced local list (no
|
||||
// position of its own yet, defaulting to 0) must also win the tiebreak
|
||||
// against any existing position-0 list by virtue of being newest.
|
||||
await fakeDb.lists.put({
|
||||
id: 'list-new',
|
||||
name: 'New',
|
||||
created_at: '2024-01-03T00:00:00.000Z',
|
||||
pendingSync: true,
|
||||
})
|
||||
await store.refresh()
|
||||
|
||||
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-new', 'list-a', 'list-b'])
|
||||
})
|
||||
|
||||
it('reorders lists optimistically and pushes the new order via the dedicated endpoint', async () => {
|
||||
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: 'list-a', name: 'A', position: 1 },
|
||||
{ id: 'list-b', name: 'B', position: 0 },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
await store.reorderLists(['list-b', 'list-a'])
|
||||
|
||||
expect(store.sortedLists.map((list) => list.id)).toEqual(['list-b', 'list-a'])
|
||||
expect(store.lists.every((list) => list.pendingSync)).toBe(true)
|
||||
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-b', 'list-a'] })
|
||||
expect(store.lists.find((list) => list.id === 'list-a')?.pendingSync).toBe(false)
|
||||
expect(store.lists.find((list) => list.id === 'list-b')?.pendingSync).toBe(false)
|
||||
expect(store.pendingCount).toBe(0)
|
||||
})
|
||||
|
||||
it('supersedes a stale queued reorder instead of replaying both', async () => {
|
||||
listsApiMocks.orderListsApi.mockResolvedValue(undefined)
|
||||
|
||||
const store = useListsStore()
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await fakeDb.lists.put({ id: 'list-b', name: 'B', position: 1, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
await store.reorderLists(['list-b', 'list-a'])
|
||||
await store.reorderLists(['list-a', 'list-b'])
|
||||
expect(store.pendingCount).toBe(1)
|
||||
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledTimes(1)
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({ list_ids: ['list-a', 'list-b'] })
|
||||
})
|
||||
|
||||
it('remaps a queued reorder entry when one of its lists gets its server id assigned mid-flight', async () => {
|
||||
listsApiMocks.createListApi.mockResolvedValueOnce({ id: 'server-list-1', name: 'New list' })
|
||||
listsApiMocks.orderListsApi.mockResolvedValueOnce(undefined)
|
||||
listsApiMocks.getListsApi.mockResolvedValueOnce([
|
||||
{ id: 'server-list-1', name: 'New list', position: 0 },
|
||||
{ id: 'list-a', name: 'A', position: 1 },
|
||||
])
|
||||
|
||||
const store = useListsStore()
|
||||
const localList = await store.createList('New list')
|
||||
await fakeDb.lists.put({ id: 'list-a', name: 'A', position: 0, pendingSync: false })
|
||||
await store.refresh()
|
||||
|
||||
// Queues an "orderLists" entry referencing the not-yet-synced localList.id,
|
||||
// created after the still-pending "createList" entry.
|
||||
await store.reorderLists([localList.id, 'list-a'])
|
||||
await store.sync()
|
||||
|
||||
expect(listsApiMocks.orderListsApi).toHaveBeenCalledWith({
|
||||
list_ids: ['server-list-1', 'list-a'],
|
||||
})
|
||||
})
|
||||
|
||||
it('serializes pullListItems() behind an in-flight sync() so they never race on the same rows', async () => {
|
||||
let resolveGetLists!: (value: unknown[]) => void
|
||||
listsApiMocks.getListsApi.mockImplementationOnce(
|
||||
|
||||
+76
-2
@@ -19,6 +19,7 @@ import {
|
||||
removeUserFromListApi,
|
||||
deleteListApi,
|
||||
deleteListItemApi,
|
||||
orderListsApi,
|
||||
} from '@/api/lists'
|
||||
|
||||
// A burst of rapid edits (ticking off several items, typing then blurring a
|
||||
@@ -42,8 +43,18 @@ export const useListsStore = defineStore('lists', () => {
|
||||
const error = ref<string | null>(null)
|
||||
const pendingCount = ref(0)
|
||||
|
||||
// Lists carry a server-assigned `position`, rearranged via reorderLists().
|
||||
// New lists (local-only or freshly synced) always come back as position 0
|
||||
// - by design, so a new list always lands at the top - which means several
|
||||
// lists can share a position. created_at desc breaks that tie, newest
|
||||
// first, and also covers a local list created but not yet synced (no
|
||||
// position of its own yet: defaults to 0 below).
|
||||
const sortedLists = computed(() =>
|
||||
[...lists.value].sort((a, b) => (b.modified_at ?? '').localeCompare(a.modified_at ?? '')),
|
||||
[...lists.value].sort((a, b) => {
|
||||
const positionDiff = (a.position ?? 0) - (b.position ?? 0)
|
||||
if (positionDiff !== 0) return positionDiff
|
||||
return (b.created_at ?? '').localeCompare(a.created_at ?? '')
|
||||
}),
|
||||
)
|
||||
|
||||
function itemsForList(listId: string) {
|
||||
@@ -245,6 +256,37 @@ export const useListsStore = defineStore('lists', () => {
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
// Applies a full reordering of the user's lists (e.g. from a drag-and-drop
|
||||
// gesture). Positions are reassigned optimistically to every list in
|
||||
// `orderedIds`, and marked pendingSync so a pull racing the queued
|
||||
// "orderLists" entry can't clobber the optimistic order before it syncs.
|
||||
async function reorderLists(orderedIds: string[]) {
|
||||
await Promise.all(
|
||||
orderedIds.map((id, index) => db.lists.update(id, { position: index, pendingSync: true })),
|
||||
)
|
||||
for (const [index, id] of orderedIds.entries()) {
|
||||
const existing = lists.value.find((entry) => entry.id === id)
|
||||
if (existing) {
|
||||
existing.position = index
|
||||
existing.pendingSync = true
|
||||
}
|
||||
}
|
||||
|
||||
// Only the latest requested order matters, so any not-yet-synced
|
||||
// "orderLists" entry is superseded rather than left to also replay.
|
||||
const staleEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
|
||||
if (staleEntries.length > 0) {
|
||||
await db.syncQueue.bulkDelete(staleEntries.map((entry) => entry.id!))
|
||||
pendingCount.value = Math.max(0, pendingCount.value - staleEntries.length)
|
||||
}
|
||||
|
||||
await enqueue({
|
||||
type: 'orderLists',
|
||||
payload: { list_ids: orderedIds },
|
||||
})
|
||||
scheduleSync()
|
||||
}
|
||||
|
||||
async function deleteListItem(itemId: string) {
|
||||
await db.listItems.delete(itemId)
|
||||
removeLocalListItem(itemId)
|
||||
@@ -292,6 +334,18 @@ export const useListsStore = defineStore('lists', () => {
|
||||
payload: updatedPayload,
|
||||
})
|
||||
}
|
||||
|
||||
// "orderLists" entries aren't tied to a single localListId (they carry
|
||||
// every list's id in payload.list_ids), so they need their own remap pass.
|
||||
const affectedOrderEntries = await db.syncQueue.where('type').equals('orderLists').toArray()
|
||||
for (const orderEntry of affectedOrderEntries) {
|
||||
if (orderEntry.type !== 'orderLists' || !orderEntry.payload.list_ids.includes(oldId)) continue
|
||||
await db.syncQueue.update(orderEntry.id!, {
|
||||
payload: {
|
||||
list_ids: orderEntry.payload.list_ids.map((id) => (id === oldId ? newId : id)),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Remaps a client-generated temporary list item id to the id assigned by
|
||||
@@ -374,6 +428,17 @@ export const useListsStore = defineStore('lists', () => {
|
||||
await deleteListItemApi(entry.payload.id)
|
||||
break
|
||||
}
|
||||
case 'orderLists': {
|
||||
await orderListsApi(entry.payload)
|
||||
await Promise.all(
|
||||
entry.payload.list_ids.map((id) => db.lists.update(id, { pendingSync: false })),
|
||||
)
|
||||
const affectedIds = new Set(entry.payload.list_ids)
|
||||
for (const list of lists.value) {
|
||||
if (affectedIds.has(list.id)) list.pendingSync = false
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -384,7 +449,15 @@ export const useListsStore = defineStore('lists', () => {
|
||||
try {
|
||||
const queue = await db.syncQueue.orderBy('createdAt').toArray()
|
||||
|
||||
for (const entry of queue) {
|
||||
for (const snapshotEntry of queue) {
|
||||
// Re-read the entry rather than trusting the queue snapshot: an
|
||||
// earlier iteration this same pass may have remapped a temporary id
|
||||
// referenced in this entry's payload (e.g. remapListId rewriting a
|
||||
// queued "orderLists" entry after its "createList" entry synced).
|
||||
const entry =
|
||||
snapshotEntry.id !== undefined
|
||||
? ((await db.syncQueue.get(snapshotEntry.id)) ?? snapshotEntry)
|
||||
: snapshotEntry
|
||||
try {
|
||||
await processSyncEntry(entry)
|
||||
if (entry.id !== undefined) {
|
||||
@@ -572,6 +645,7 @@ export const useListsStore = defineStore('lists', () => {
|
||||
removeUserFromList,
|
||||
deleteList,
|
||||
deleteListItem,
|
||||
reorderLists,
|
||||
sync,
|
||||
pullFromServer,
|
||||
pullListItems,
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface List {
|
||||
modified_at?: string
|
||||
total_items?: number
|
||||
completed_items?: number
|
||||
position?: number
|
||||
}
|
||||
|
||||
export interface ListItem {
|
||||
@@ -42,3 +43,7 @@ export interface RemoveUserFromListPayload {
|
||||
list_id: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface OrderListsPayload {
|
||||
list_ids: string[]
|
||||
}
|
||||
|
||||
+55
-5
@@ -1,10 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { useListsStore } from '@/stores/lists'
|
||||
import type { LocalList } from '@/database/db'
|
||||
import ListCard from '@/components/ListCard.vue'
|
||||
import ShareListModal from '@/components/ShareListModal.vue'
|
||||
import DeleteListModal from '@/components/DeleteListModal.vue'
|
||||
import { useListDragReorder } from '@/composables/useListDragReorder'
|
||||
|
||||
const listsStore = useListsStore()
|
||||
|
||||
@@ -14,6 +15,26 @@ const createError = ref('')
|
||||
const sharingList = ref<LocalList | null>(null)
|
||||
const deletingList = ref<LocalList | null>(null)
|
||||
|
||||
// Local, reorderable copy of the store's list order. Kept in sync with
|
||||
// listsStore.sortedLists except while a drag is in progress, so a
|
||||
// mid-sync-pass update (e.g. total_items ticking over) can't yank a row out
|
||||
// from under the user's finger.
|
||||
const displayedLists = ref<LocalList[]>([])
|
||||
const { draggingId, isPointerActive, dragOffsetPx, setItemRef, onPointerDown } = useListDragReorder(
|
||||
displayedLists,
|
||||
(orderedIds) => {
|
||||
void listsStore.reorderLists(orderedIds)
|
||||
},
|
||||
)
|
||||
|
||||
watch(
|
||||
() => listsStore.sortedLists,
|
||||
(next) => {
|
||||
if (draggingId.value === null) displayedLists.value = [...next]
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
listsStore.loadLists()
|
||||
})
|
||||
@@ -86,11 +107,24 @@ async function handleCreateList() {
|
||||
|
||||
<p v-if="createError" class="banner banner-error">{{ createError }}</p>
|
||||
|
||||
<ul v-if="listsStore.sortedLists.length > 0" class="lists">
|
||||
<li v-for="list in listsStore.sortedLists" :key="list.id">
|
||||
<ListCard :list="list" @share="handleOpenShare(list)" @delete="handleOpenDelete(list)" />
|
||||
<TransitionGroup v-if="displayedLists.length > 0" tag="ul" name="list-reorder" class="lists">
|
||||
<li
|
||||
v-for="list in displayedLists"
|
||||
:key="list.id"
|
||||
:ref="(el) => setItemRef(list.id, el as Element | null)"
|
||||
class="list-row"
|
||||
:class="{ 'no-transition': isPointerActive && draggingId === list.id }"
|
||||
:style="draggingId === list.id ? { transform: `translateY(${dragOffsetPx}px)` } : undefined"
|
||||
>
|
||||
<ListCard
|
||||
:list="list"
|
||||
:dragging="draggingId === list.id"
|
||||
@share="handleOpenShare(list)"
|
||||
@delete="handleOpenDelete(list)"
|
||||
@handle-pointerdown="onPointerDown(list.id, $event)"
|
||||
/>
|
||||
</li>
|
||||
</ul>
|
||||
</TransitionGroup>
|
||||
|
||||
<div v-else class="empty-state">
|
||||
<p>No lists yet</p>
|
||||
@@ -145,6 +179,22 @@ h1 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.list-row {
|
||||
transition:
|
||||
transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
|
||||
z-index 0s;
|
||||
}
|
||||
|
||||
.list-row.no-transition {
|
||||
transition: none;
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.list-reorder-move {
|
||||
transition: transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 3rem 1rem;
|
||||
|
||||
Reference in New Issue
Block a user