feat: add resizable desktop panes
This commit is contained in:
@@ -0,0 +1,89 @@
|
|||||||
|
import type { APIRequestContext, Page } from '@playwright/test'
|
||||||
|
import { expect, test } from './fixtures'
|
||||||
|
|
||||||
|
async function csrf(request: APIRequestContext) {
|
||||||
|
const state = await request.storageState()
|
||||||
|
return state.cookies.find(cookie => cookie.name === 'dodo_csrf')?.value ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask(request: APIRequestContext, baseURL: string, title: string) {
|
||||||
|
const bootstrap = await request.get('/api/v1/bootstrap')
|
||||||
|
expect(bootstrap.ok(), await bootstrap.text()).toBeTruthy()
|
||||||
|
const inbox = (await bootstrap.json()).lists.find((item: { is_inbox: boolean }) => item.is_inbox)
|
||||||
|
const response = await request.post('/api/v1/tasks', {
|
||||||
|
data: { title, list_id: inbox.id },
|
||||||
|
headers: { 'x-csrf-token': await csrf(request), origin: baseURL },
|
||||||
|
})
|
||||||
|
expect(response.ok(), await response.text()).toBeTruthy()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dragSeparator(page: Page, label: string, deltaX: number) {
|
||||||
|
const separator = page.getByRole('separator', { name: label })
|
||||||
|
const box = await separator.boundingBox()
|
||||||
|
expect(box).not.toBeNull()
|
||||||
|
const x = box!.x + box!.width / 2
|
||||||
|
const y = box!.y + Math.min(120, box!.height / 2)
|
||||||
|
await page.mouse.move(x, y)
|
||||||
|
await page.mouse.down()
|
||||||
|
await page.mouse.move(x + deltaX, y, { steps: 5 })
|
||||||
|
await page.mouse.up()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('desktop panes resize by pointer and keyboard and restore after reload', async ({ page, request, baseURL }, testInfo) => {
|
||||||
|
test.skip((page.viewportSize()?.width ?? 0) <= 930, 'desktop-only behavior')
|
||||||
|
const title = `三栏拖动-${testInfo.project.name}-${Date.now()}`
|
||||||
|
await createTask(request, baseURL!, title)
|
||||||
|
await page.goto('/')
|
||||||
|
|
||||||
|
const sidebar = page.locator('.sidebar')
|
||||||
|
const main = page.locator('main')
|
||||||
|
const sidebarSeparator = page.getByRole('separator', { name: '调整菜单栏宽度' })
|
||||||
|
await expect(sidebarSeparator).toBeVisible()
|
||||||
|
await expect(sidebarSeparator).toHaveAttribute('aria-valuenow', '236')
|
||||||
|
|
||||||
|
const sidebarBefore = await sidebar.boundingBox()
|
||||||
|
await dragSeparator(page, '调整菜单栏宽度', 64)
|
||||||
|
const sidebarDraggedWidth = Number(await sidebarSeparator.getAttribute('aria-valuenow'))
|
||||||
|
expect(sidebarDraggedWidth - sidebarBefore!.width).toBeGreaterThanOrEqual(63)
|
||||||
|
expect(sidebarDraggedWidth - sidebarBefore!.width).toBeLessThanOrEqual(65)
|
||||||
|
await expect(sidebar).toHaveCSS('width', `${sidebarDraggedWidth}px`)
|
||||||
|
const sidebarAfter = await sidebar.boundingBox()
|
||||||
|
expect((await main.boundingBox())!.x).toBeCloseTo(sidebarAfter!.x + sidebarAfter!.width, 0)
|
||||||
|
|
||||||
|
await sidebarSeparator.press('ArrowLeft')
|
||||||
|
const sidebarKeyboardWidth = sidebarDraggedWidth - 12
|
||||||
|
await expect(sidebarSeparator).toHaveAttribute('aria-valuenow', String(sidebarKeyboardWidth))
|
||||||
|
await expect(sidebar).toHaveCSS('width', `${sidebarKeyboardWidth}px`)
|
||||||
|
|
||||||
|
await sidebar.getByRole('button', { name: '收集箱', exact: true }).click()
|
||||||
|
const row = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||||
|
await expect(row).toHaveCount(1)
|
||||||
|
await row.locator('.task-main').click()
|
||||||
|
|
||||||
|
const detail = page.getByRole('dialog', { name: '任务详情' })
|
||||||
|
const detailSeparator = page.getByRole('separator', { name: '调整任务详情宽度' })
|
||||||
|
await expect(detailSeparator).toBeVisible()
|
||||||
|
const detailBefore = await detail.boundingBox()
|
||||||
|
await dragSeparator(page, '调整任务详情宽度', -70)
|
||||||
|
const detailAfter = await detail.boundingBox()
|
||||||
|
const expectedDetailWidth = Math.min(detailBefore!.width + 70, (page.viewportSize()?.width ?? 0) - sidebarKeyboardWidth - 330)
|
||||||
|
expect(detailAfter!.width).toBeCloseTo(expectedDetailWidth, 0)
|
||||||
|
const mainAfter = await main.boundingBox()
|
||||||
|
expect(mainAfter!.x + mainAfter!.width).toBeCloseTo(detailAfter!.x, 0)
|
||||||
|
|
||||||
|
const widths = await page.evaluate(() => ({
|
||||||
|
sidebar: localStorage.getItem('dodo.desktop-pane.sidebar'),
|
||||||
|
detail: localStorage.getItem('dodo.desktop-pane.detail'),
|
||||||
|
}))
|
||||||
|
expect(widths).toEqual({ sidebar: String(Math.round(sidebarKeyboardWidth)), detail: String(Math.round(detailAfter!.width)) })
|
||||||
|
|
||||||
|
await page.reload()
|
||||||
|
await expect(sidebar).toHaveCSS('width', `${Math.round(sidebarKeyboardWidth)}px`)
|
||||||
|
const restoredRow = page.locator('.task-row').filter({ has: page.locator('strong', { hasText: title }) })
|
||||||
|
await restoredRow.locator('.task-main').click()
|
||||||
|
await expect(detail).toHaveCSS('width', `${Math.round(detailAfter!.width)}px`)
|
||||||
|
|
||||||
|
await page.setViewportSize({ width: 930, height: 900 })
|
||||||
|
await expect(sidebarSeparator).toBeHidden()
|
||||||
|
await expect(detailSeparator).toBeHidden()
|
||||||
|
})
|
||||||
@@ -75,7 +75,7 @@ test('task detail paper flow keeps approved responsive geometry and material', a
|
|||||||
expect(Math.abs((footerBox.y + footerBox.height) - (detailBox.y + detailBox.height))).toBeLessThanOrEqual(2)
|
expect(Math.abs((footerBox.y + footerBox.height) - (detailBox.y + detailBox.height))).toBeLessThanOrEqual(2)
|
||||||
|
|
||||||
if (viewport.width >= 931) {
|
if (viewport.width >= 931) {
|
||||||
expect(metrics.width).toBeCloseTo(350, 0)
|
expect(metrics.width).toBeCloseTo(Number(await page.getByRole('separator', { name: '调整任务详情宽度' }).getAttribute('aria-valuenow')), 0)
|
||||||
const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect()))
|
const columns = await dateTime.locator('.task-detail-field').evaluateAll(elements => elements.map(element => element.getBoundingClientRect()))
|
||||||
expect(columns[0].top).toBeCloseTo(columns[1].top, 0)
|
expect(columns[0].top).toBeCloseTo(columns[1].top, 0)
|
||||||
expect(columns[1].left - columns[0].right).toBeCloseTo(10, 0)
|
expect(columns[1].left - columns[0].right).toBeCloseTo(10, 0)
|
||||||
|
|||||||
+80
-3
@@ -11,6 +11,7 @@ import { csrfHeader } from './lib/csrf'
|
|||||||
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
import { createCompletionPulse, shouldAnimateCompletionExit, waitForCompletionExit } from './lib/completion-motion'
|
||||||
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
import { captureListDragPointer, getAdjacentListMove, hasExceededLongPressMovement, moveListToScope, snapshotListDragPointer, type ListDragPointer } from './lib/list-drag'
|
||||||
import { deriveMemoShellState } from './lib/app-shell-state'
|
import { deriveMemoShellState } from './lib/app-shell-state'
|
||||||
|
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth, type DesktopPane } from './lib/desktop-shell-resize'
|
||||||
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
import { positionArchivedMenu, resolveArchivedMenuFocusTarget } from './lib/archived-list-menu'
|
||||||
import MvpPanel from './MvpPanel.vue'
|
import MvpPanel from './MvpPanel.vue'
|
||||||
import CountdownPanel from './CountdownPanel.vue'
|
import CountdownPanel from './CountdownPanel.vue'
|
||||||
@@ -81,6 +82,18 @@ const loading = ref(false)
|
|||||||
const taskDueNowMs = useTaskDueClock()
|
const taskDueNowMs = useTaskDueClock()
|
||||||
const mobileSidebar = ref(false)
|
const mobileSidebar = ref(false)
|
||||||
const sidebarCollapsed = ref(false)
|
const sidebarCollapsed = ref(false)
|
||||||
|
const SIDEBAR_WIDTH_STORAGE_KEY = 'dodo.desktop-pane.sidebar'
|
||||||
|
const DETAIL_WIDTH_STORAGE_KEY = 'dodo.desktop-pane.detail'
|
||||||
|
const desktopViewportWidth = ref(window.innerWidth)
|
||||||
|
const sidebarWidth = ref(readDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, 236))
|
||||||
|
const detailWidth = ref(readDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, 350))
|
||||||
|
const resizingPane = ref<DesktopPane | null>(null)
|
||||||
|
const sidebarMaxWidth = computed(() => getDesktopPaneMax('sidebar', desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0))
|
||||||
|
const detailMaxWidth = computed(() => getDesktopPaneMax('detail', desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value))
|
||||||
|
const detailSeparatorLabel = computed(() => habitDetailOpen.value && !selectedTask.value ? '调整习惯详情宽度' : '调整任务详情宽度')
|
||||||
|
const shellStyle = computed(() => ({ '--sidebar-width': `${sidebarWidth.value}px`, '--detail-width': `${detailWidth.value}px` }))
|
||||||
|
let paneResizePointerId: number | null = null
|
||||||
|
let paneResizeCaptureTarget: HTMLElement | null = null
|
||||||
const mobileDetail = ref(false)
|
const mobileDetail = ref(false)
|
||||||
const moreSettingsOpen = ref(false)
|
const moreSettingsOpen = ref(false)
|
||||||
const markdownPreview = ref(false)
|
const markdownPreview = ref(false)
|
||||||
@@ -169,7 +182,7 @@ const memoPanel = ref<InstanceType<typeof MemoPanel> | null>(null)
|
|||||||
const memoTrash = ref(false)
|
const memoTrash = ref(false)
|
||||||
const memoDetailOpen = ref(false)
|
const memoDetailOpen = ref(false)
|
||||||
const habitDetailOpen = ref(false)
|
const habitDetailOpen = ref(false)
|
||||||
const compactLayout = ref(window.innerWidth <= 930)
|
const compactLayout = ref(desktopViewportWidth.value <= 930)
|
||||||
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
|
const memoShellState = computed(() => deriveMemoShellState({ view: activeView.value, detailOpen: memoDetailOpen.value, compact: compactLayout.value, trash: memoTrash.value }))
|
||||||
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
|
const memoBackgroundInert = computed(() => memoShellState.value.backgroundInert)
|
||||||
const showFloatingAdd = computed(() => memoShellState.value.showFab)
|
const showFloatingAdd = computed(() => memoShellState.value.showFab)
|
||||||
@@ -318,6 +331,44 @@ function toggleSidebar() {
|
|||||||
sidebarCollapsed.value = !sidebarCollapsed.value
|
sidebarCollapsed.value = !sidebarCollapsed.value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
function stopPaneResize(event?: PointerEvent) {
|
||||||
|
if (!resizingPane.value || (event && paneResizePointerId !== null && event.pointerId !== paneResizePointerId)) return
|
||||||
|
const pane = resizingPane.value
|
||||||
|
const pointerId = paneResizePointerId
|
||||||
|
const captureTarget = paneResizeCaptureTarget
|
||||||
|
if (pane === 'sidebar') writeDesktopPaneWidth(window.localStorage, SIDEBAR_WIDTH_STORAGE_KEY, sidebarWidth.value)
|
||||||
|
else writeDesktopPaneWidth(window.localStorage, DETAIL_WIDTH_STORAGE_KEY, detailWidth.value)
|
||||||
|
resizingPane.value = null
|
||||||
|
paneResizePointerId = null
|
||||||
|
paneResizeCaptureTarget = null
|
||||||
|
if (captureTarget && pointerId !== null && captureTarget.hasPointerCapture?.(pointerId)) captureTarget.releasePointerCapture(pointerId)
|
||||||
|
}
|
||||||
|
function handlePaneLostPointerCapture(event: PointerEvent) {
|
||||||
|
if (event.pointerId === paneResizePointerId) stopPaneResize(event)
|
||||||
|
}
|
||||||
|
function movePaneResize(event: PointerEvent) {
|
||||||
|
if (!resizingPane.value || event.pointerId !== paneResizePointerId) return
|
||||||
|
if (event.buttons === 0) { stopPaneResize(); return }
|
||||||
|
const nextWidth = resizingPane.value === 'sidebar' ? event.clientX : window.innerWidth - event.clientX
|
||||||
|
if (resizingPane.value === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', nextWidth, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
|
||||||
|
else detailWidth.value = clampDesktopPaneWidth('detail', nextWidth, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value)
|
||||||
|
}
|
||||||
|
function startPaneResize(pane: DesktopPane, event: PointerEvent) {
|
||||||
|
if (event.button !== 0 || compactLayout.value || (pane === 'detail' && !selectedTask.value && !habitDetailOpen.value)) return
|
||||||
|
event.preventDefault()
|
||||||
|
paneResizeCaptureTarget = event.currentTarget as HTMLElement
|
||||||
|
paneResizeCaptureTarget.setPointerCapture?.(event.pointerId)
|
||||||
|
resizingPane.value = pane
|
||||||
|
paneResizePointerId = event.pointerId
|
||||||
|
}
|
||||||
|
function resizePaneWithKeyboard(pane: DesktopPane, event: KeyboardEvent) {
|
||||||
|
if (!['ArrowLeft', 'ArrowRight'].includes(event.key)) return
|
||||||
|
event.preventDefault()
|
||||||
|
const direction = event.key === 'ArrowRight' ? 1 : -1
|
||||||
|
if (pane === 'sidebar') sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value + direction * 12, window.innerWidth, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
|
||||||
|
else detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value - direction * 12, window.innerWidth, sidebarCollapsed.value ? 0 : sidebarWidth.value)
|
||||||
|
writeDesktopPaneWidth(window.localStorage, pane === 'sidebar' ? SIDEBAR_WIDTH_STORAGE_KEY : DETAIL_WIDTH_STORAGE_KEY, pane === 'sidebar' ? sidebarWidth.value : detailWidth.value)
|
||||||
|
}
|
||||||
|
|
||||||
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
const appDialog = ref<{ show: (options: AppDialogOptions) => Promise<boolean | string | null> } | null>(null)
|
||||||
async function confirmAction(title: string, description?: string, danger = false) {
|
async function confirmAction(title: string, description?: string, danger = false) {
|
||||||
@@ -1488,15 +1539,35 @@ function nextPage() {
|
|||||||
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
activeView.value === 'trash' ? loadTrash() : isTaskView(activeView.value) ? loadAll() : undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reconcileDesktopPaneWidths() {
|
||||||
|
if (compactLayout.value) return
|
||||||
|
if (selectedTask.value || habitDetailOpen.value) {
|
||||||
|
detailWidth.value = clampDesktopPaneWidth('detail', detailWidth.value, desktopViewportWidth.value, sidebarCollapsed.value ? 0 : sidebarWidth.value)
|
||||||
|
}
|
||||||
|
sidebarWidth.value = clampDesktopPaneWidth('sidebar', sidebarWidth.value, desktopViewportWidth.value, selectedTask.value || habitDetailOpen.value ? detailWidth.value : 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch([selectedTask, habitDetailOpen, sidebarCollapsed], reconcileDesktopPaneWidths)
|
||||||
|
|
||||||
|
function handlePaneResizeBlur() { stopPaneResize() }
|
||||||
|
|
||||||
function handleViewportResize() {
|
function handleViewportResize() {
|
||||||
compactLayout.value = window.innerWidth <= 930
|
desktopViewportWidth.value = window.innerWidth
|
||||||
|
compactLayout.value = desktopViewportWidth.value <= 930
|
||||||
|
if (!compactLayout.value) reconcileDesktopPaneWidths()
|
||||||
|
else stopPaneResize()
|
||||||
handleArchivedListViewportChange()
|
handleArchivedListViewportChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
handleViewportResize()
|
||||||
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
document.addEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||||
document.addEventListener('keydown', handleArchivedListEscape)
|
document.addEventListener('keydown', handleArchivedListEscape)
|
||||||
window.addEventListener('resize', handleViewportResize)
|
window.addEventListener('resize', handleViewportResize)
|
||||||
|
window.addEventListener('pointermove', movePaneResize)
|
||||||
|
window.addEventListener('pointerup', stopPaneResize)
|
||||||
|
window.addEventListener('pointercancel', stopPaneResize)
|
||||||
|
window.addEventListener('blur', handlePaneResizeBlur)
|
||||||
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
|
document.addEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||||
window.addEventListener('focus', handleTodayEnvironmentResume)
|
window.addEventListener('focus', handleTodayEnvironmentResume)
|
||||||
window.addEventListener('pageshow', handleTodayEnvironmentResume)
|
window.addEventListener('pageshow', handleTodayEnvironmentResume)
|
||||||
@@ -1507,6 +1578,10 @@ onUnmounted(() => {
|
|||||||
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
document.removeEventListener('pointerdown', handleArchivedListOutsidePointer)
|
||||||
document.removeEventListener('keydown', handleArchivedListEscape)
|
document.removeEventListener('keydown', handleArchivedListEscape)
|
||||||
window.removeEventListener('resize', handleViewportResize)
|
window.removeEventListener('resize', handleViewportResize)
|
||||||
|
window.removeEventListener('pointermove', movePaneResize)
|
||||||
|
window.removeEventListener('pointerup', stopPaneResize)
|
||||||
|
window.removeEventListener('pointercancel', stopPaneResize)
|
||||||
|
window.removeEventListener('blur', handlePaneResizeBlur)
|
||||||
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
|
document.removeEventListener('visibilitychange', handleTodayEnvironmentResume)
|
||||||
window.removeEventListener('focus', handleTodayEnvironmentResume)
|
window.removeEventListener('focus', handleTodayEnvironmentResume)
|
||||||
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
|
window.removeEventListener('pageshow', handleTodayEnvironmentResume)
|
||||||
@@ -1527,7 +1602,7 @@ onUnmounted(() => {
|
|||||||
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
|
<p v-if="initialized" class="auth-footnote">登录后继续你的清单</p>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar }">
|
<div v-else class="shell" :class="{ 'today-active': activeView==='today', 'sidebar-collapsed': sidebarCollapsed, 'detail-open': Boolean(selectedTask) || habitDetailOpen, 'memo-detail-open': activeView==='memos' && memoDetailOpen, 'mobile-sidebar-open': mobileSidebar, 'pane-resizing': Boolean(resizingPane) }" :style="shellStyle">
|
||||||
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
<div v-if="mobileSidebar || mobileDetail" class="scrim" @click="mobileSidebar=false;mobileDetail=false" />
|
||||||
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
|
<aside class="sidebar" :inert="memoBackgroundInert ? true : undefined" :class="{ open: mobileSidebar }" @keydown.esc="sidebarCreateOpen=false;closeArchivedListAction();closeSidebarAction()">
|
||||||
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
<div class="brand-row"><div class="brand small brand-lockup"><img class="brand-logo" src="/dodo-logo.svg" alt=""><span class="brand-wordmark">dodo</span></div><button class="icon mobile-only" aria-label="关闭菜单" @click="mobileSidebar=false"><X /></button></div>
|
||||||
@@ -1598,6 +1673,7 @@ onUnmounted(() => {
|
|||||||
</template>
|
</template>
|
||||||
</AppSheet>
|
</AppSheet>
|
||||||
</aside>
|
</aside>
|
||||||
|
<div v-if="!sidebarCollapsed" class="pane-resizer pane-resizer--sidebar" role="separator" aria-label="调整菜单栏宽度" aria-orientation="vertical" aria-valuemin="190" :aria-valuemax="sidebarMaxWidth" :aria-valuenow="sidebarWidth" tabindex="0" @pointerdown="startPaneResize('sidebar',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('sidebar',$event)" />
|
||||||
|
|
||||||
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
|
<main :class="{'today-main':activeView==='today','list-main':activeView==='tasks'||activeView==='upcoming'||activeView==='habits'}">
|
||||||
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
|
<header class="topbar" :class="{'settings-topbar':activeView==='settings'}" :inert="memoBackgroundInert ? true : undefined">
|
||||||
@@ -1656,6 +1732,7 @@ onUnmounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</main>
|
</main>
|
||||||
|
<div v-if="selectedTask || habitDetailOpen" class="pane-resizer pane-resizer--detail" role="separator" :aria-label="detailSeparatorLabel" aria-orientation="vertical" aria-valuemin="300" :aria-valuemax="detailMaxWidth" :aria-valuenow="detailWidth" tabindex="0" @pointerdown="startPaneResize('detail',$event)" @lostpointercapture="handlePaneLostPointerCapture" @keydown="resizePaneWithKeyboard('detail',$event)" />
|
||||||
|
|
||||||
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
|
<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout" variant="detail" panel-class="detail" title-id="task-detail-title" :close-on-scrim="compactLayout" :busy="taskDetailBusy" @close="closeTaskDetail" @submit.prevent="saveSelectedTaskChanges">
|
||||||
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
<div class="detail-head"><span id="task-detail-title">任务详情</span><button class="icon" type="button" :disabled="taskDetailBusy" aria-label="关闭详情" @click="closeTaskDetail"><X/></button></div>
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { clampDesktopPaneWidth, getDesktopPaneMax, readDesktopPaneWidth, writeDesktopPaneWidth } from './desktop-shell-resize'
|
||||||
|
|
||||||
|
describe('desktop shell resize', () => {
|
||||||
|
it('clamps the sidebar while preserving the center and detail panes', () => {
|
||||||
|
expect(clampDesktopPaneWidth('sidebar', 120, 1440, 350)).toBe(190)
|
||||||
|
expect(clampDesktopPaneWidth('sidebar', 500, 1440, 350)).toBe(360)
|
||||||
|
expect(clampDesktopPaneWidth('sidebar', 360, 931, 350)).toBe(251)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('clamps the detail pane while preserving the center and sidebar panes', () => {
|
||||||
|
expect(clampDesktopPaneWidth('detail', 240, 1440, 236)).toBe(300)
|
||||||
|
expect(clampDesktopPaneWidth('detail', 600, 1440, 236)).toBe(520)
|
||||||
|
expect(clampDesktopPaneWidth('detail', 520, 931, 236)).toBe(365)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports the effective maximum at narrow desktop widths', () => {
|
||||||
|
expect(getDesktopPaneMax('sidebar', 931, 350)).toBe(251)
|
||||||
|
expect(getDesktopPaneMax('detail', 931, 236)).toBe(365)
|
||||||
|
expect(getDesktopPaneMax('detail', 1440, 236)).toBe(520)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores only finite persisted widths', () => {
|
||||||
|
const storage = { getItem: (key: string) => key.endsWith('sidebar') ? '288' : 'oops' }
|
||||||
|
expect(readDesktopPaneWidth(storage, 'dodo.desktop.sidebar', 236)).toBe(288)
|
||||||
|
expect(readDesktopPaneWidth(storage, 'dodo.desktop.detail', 350)).toBe(350)
|
||||||
|
expect(readDesktopPaneWidth({ getItem: () => { throw new Error('blocked') } }, 'key', 236)).toBe(236)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores storage write failures', () => {
|
||||||
|
expect(() => writeDesktopPaneWidth({ setItem: () => { throw new Error('blocked') } }, 'key', 300)).not.toThrow()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
export type DesktopPane = 'sidebar' | 'detail'
|
||||||
|
|
||||||
|
const SIDEBAR_MIN = 190
|
||||||
|
const SIDEBAR_MAX = 360
|
||||||
|
const DETAIL_MIN = 300
|
||||||
|
const DETAIL_MAX = 520
|
||||||
|
const CENTER_MIN = 330
|
||||||
|
|
||||||
|
export function getDesktopPaneMax(pane: DesktopPane, viewportWidth: number, otherPaneWidth: number) {
|
||||||
|
const lower = pane === 'sidebar' ? SIDEBAR_MIN : DETAIL_MIN
|
||||||
|
const upper = pane === 'sidebar' ? SIDEBAR_MAX : DETAIL_MAX
|
||||||
|
return Math.round(Math.min(upper, Math.max(lower, viewportWidth - otherPaneWidth - CENTER_MIN)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampDesktopPaneWidth(pane: DesktopPane, width: number, viewportWidth: number, otherPaneWidth: number) {
|
||||||
|
const lower = pane === 'sidebar' ? SIDEBAR_MIN : DETAIL_MIN
|
||||||
|
return Math.round(Math.min(Math.max(width, lower), getDesktopPaneMax(pane, viewportWidth, otherPaneWidth)))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readDesktopPaneWidth(storage: Pick<Storage, 'getItem'>, key: string, fallback: number) {
|
||||||
|
try {
|
||||||
|
const value = Number(storage.getItem(key))
|
||||||
|
return Number.isFinite(value) && value > 0 ? value : fallback
|
||||||
|
} catch {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeDesktopPaneWidth(storage: Pick<Storage, 'setItem'>, key: string, width: number) {
|
||||||
|
try { storage.setItem(key, String(width)) } catch { /* storage can be unavailable */ }
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
+21
-11
@@ -389,8 +389,8 @@ describe('approved UI detail direction', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('matches the approved paper-flow geometry and material', () => {
|
it('matches the approved paper-flow geometry and material', () => {
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:236px minmax(0,1fr) 350px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
||||||
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}')
|
expect(css).toContain('.detail{min-width:0;border-left:1px solid var(--line);background:#fffdf8;overflow:hidden;display:flex;flex-direction:column}')
|
||||||
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
|
expect(css).toContain('.detail-head{height:58px;flex:0 0 58px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;')
|
||||||
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
|
expect(css).toContain('.detail-form{min-width:0;border:0;margin:0;grid-template-columns:minmax(0,1fr);padding:18px;display:grid;gap:18px;overflow-y:auto;')
|
||||||
@@ -1467,6 +1467,15 @@ describe('unified floating add interaction', () => {
|
|||||||
describe('desktop task and habit detail disclosure', () => {
|
describe('desktop task and habit detail disclosure', () => {
|
||||||
it('gives the list the full remaining width until a task or habit is selected', () => {
|
it('gives the list the full remaining width until a task or habit is selected', () => {
|
||||||
expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen")
|
expect(app).toContain("'detail-open': Boolean(selectedTask) || habitDetailOpen")
|
||||||
|
expect(app).toContain(':style="shellStyle"')
|
||||||
|
expect(app).toContain('aria-label="调整菜单栏宽度"')
|
||||||
|
expect(app).toContain(':aria-label="detailSeparatorLabel"')
|
||||||
|
expect(app).toContain(':aria-valuemax="sidebarMaxWidth"')
|
||||||
|
expect(app).toContain(':aria-valuemax="detailMaxWidth"')
|
||||||
|
expect(app).toContain('@lostpointercapture="handlePaneLostPointerCapture"')
|
||||||
|
expect(app).toContain("@pointerdown=\"startPaneResize('sidebar',$event)\"")
|
||||||
|
expect(app).toContain("@pointerdown=\"startPaneResize('detail',$event)\"")
|
||||||
|
expect(app).toContain("@keydown=\"resizePaneWithKeyboard('sidebar',$event)\"")
|
||||||
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
|
expect(app).toContain('<AppSheet v-if="selectedTask" :open="true" :modal="compactLayout"')
|
||||||
expect(app).toContain('panel-class="detail"')
|
expect(app).toContain('panel-class="detail"')
|
||||||
expect(app).toContain(':compact-layout="compactLayout"')
|
expect(app).toContain(':compact-layout="compactLayout"')
|
||||||
@@ -1483,10 +1492,11 @@ describe('desktop task and habit detail disclosure', () => {
|
|||||||
expect(app).toContain('@click="closeTaskDetail"')
|
expect(app).toContain('@click="closeTaskDetail"')
|
||||||
expect(app).toContain('function closeTaskDetail()')
|
expect(app).toContain('function closeTaskDetail()')
|
||||||
expect(app).not.toContain('<div v-else class="paper">')
|
expect(app).not.toContain('<div v-else class="paper">')
|
||||||
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:236px minmax(430px,1fr) 0;')
|
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
|
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('@media(min-width:931px){.habit-detail-sheet{width:350px;height:100vh;max-height:none;border-radius:0;')
|
expect(css).toContain('.pane-resizer{position:relative;z-index:12;width:9px;')
|
||||||
|
expect(css).toContain('@media(min-width:931px){.habit-detail-sheet{width:var(--detail-width,350px);height:100vh;')
|
||||||
expect(css).toContain('transition:grid-template-columns .22s ease')
|
expect(css).toContain('transition:grid-template-columns .22s ease')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1500,8 +1510,8 @@ describe('desktop sidebar collapse & folder scrollbar', () => {
|
|||||||
it('lets the top menu collapse the desktop sidebar', () => {
|
it('lets the top menu collapse the desktop sidebar', () => {
|
||||||
expect(app).toContain('sidebar-collapsed')
|
expect(app).toContain('sidebar-collapsed')
|
||||||
expect(app).toContain('toggleSidebar')
|
expect(app).toContain('toggleSidebar')
|
||||||
expect(css).toContain('.shell.sidebar-collapsed{grid-template-columns:0 minmax(430px,1fr) 0}')
|
expect(css).toContain('.shell.sidebar-collapsed{grid-template-columns:0 minmax(330px,1fr) 0}')
|
||||||
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(430px,1fr) 350px}')
|
expect(css).toContain('.shell.sidebar-collapsed.detail-open{grid-template-columns:0 minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('hides the folders scrollbar while staying scrollable', () => {
|
it('hides the folders scrollbar while staying scrollable', () => {
|
||||||
@@ -1575,13 +1585,13 @@ describe('sidebar information hierarchy', () => {
|
|||||||
|
|
||||||
describe('quiet index sidebar parity', () => {
|
describe('quiet index sidebar parity', () => {
|
||||||
it('matches the approved desktop and mobile sidebar geometry and material', () => {
|
it('matches the approved desktop and mobile sidebar geometry and material', () => {
|
||||||
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:236px minmax(430px,1fr) 0;')
|
expect(css).toContain('.shell{height:100vh;display:grid;grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) 0;')
|
||||||
expect(css).toContain('.shell.detail-open{grid-template-columns:236px minmax(430px,1fr) 350px}')
|
expect(css).toContain('.shell.detail-open{grid-template-columns:var(--sidebar-width,236px) minmax(330px,1fr) var(--detail-width,350px)}')
|
||||||
expect(css).toContain('.sidebar{border-right:1px solid #e4d5c3;background:#f7efe3;')
|
expect(css).toContain('.sidebar{border-right:1px solid #e4d5c3;background:#f7efe3;')
|
||||||
expect(css).toContain('.brand-row{height:68px;')
|
expect(css).toContain('.brand-row{height:68px;')
|
||||||
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
expect(css).toContain('border-bottom:1px solid rgba(222,205,185,.75)')
|
||||||
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
|
expect(css).toContain('.primary-nav{display:grid;padding:10px 11px 8px;gap:2px}')
|
||||||
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){.shell.detail-open{grid-template-columns:236px minmax(0,1fr) 350px}')
|
expect(css).toContain('@media(max-width:1050px) and (min-width:931px){main{padding-inline:24px}')
|
||||||
expect(css).not.toContain('grid-template-columns:220px')
|
expect(css).not.toContain('grid-template-columns:220px')
|
||||||
expect(css).toContain('@media(max-width:930px){.shell')
|
expect(css).toContain('@media(max-width:930px){.shell')
|
||||||
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
expect(css).toContain('left:0;width:236px;max-width:86vw;')
|
||||||
|
|||||||
Reference in New Issue
Block a user