mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-02-01 14:44:05 +00:00
feat(tasks): scroll to bottom in task detail view when comments are available (#1995)
Added a scroll-to-bottom button in task detail view that appears when content is scrollable and hides when users reach the bottom. The button provides quick navigation to view all content. 🐰 A button appears when scrolls grow tall, Through DOM observers, we heed the call, With smooth scroll dances to content's end, The rabbit's gift—no need to scroll and rend! ✨
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -46,4 +46,4 @@ devenv.local.nix
|
||||
/.claude/settings.local.json
|
||||
PLAN.md
|
||||
/.crush/
|
||||
|
||||
/.playwright-mcp
|
||||
|
||||
@@ -832,6 +832,7 @@
|
||||
"back": "Back to project",
|
||||
"due": "Due {at}",
|
||||
"closePopup": "Close popup",
|
||||
"scrollToBottom": "Scroll to bottom",
|
||||
"organization": "Organization",
|
||||
"management": "Management",
|
||||
"dateAndTime": "Date and time",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<div
|
||||
ref="taskViewContainer"
|
||||
class="loader-container task-view-container"
|
||||
:class="{
|
||||
'is-loading': taskService.loading || !visible,
|
||||
@@ -407,6 +408,12 @@
|
||||
:project-id="task.projectId"
|
||||
:initial-comments="task.comments"
|
||||
/>
|
||||
|
||||
<!-- Marker element for scroll-to-bottom button visibility -->
|
||||
<div
|
||||
ref="contentBottomMarker"
|
||||
class="content-bottom-marker"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Task Actions -->
|
||||
@@ -575,6 +582,16 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<BaseButton
|
||||
v-if="showScrollToCommentsButton"
|
||||
v-tooltip="$t('task.detail.scrollToBottom')"
|
||||
class="scroll-to-comments-button d-print-none"
|
||||
:aria-label="$t('task.detail.scrollToBottom')"
|
||||
@click="scrollToBottom"
|
||||
>
|
||||
<Icon icon="chevron-down" />
|
||||
</BaseButton>
|
||||
|
||||
<Modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@@ -601,7 +618,7 @@ import {ref, reactive, shallowReactive, computed, watch, nextTick, onMounted, on
|
||||
import {useRouter, useRoute, type RouteLocation, onBeforeRouteLeave} from 'vue-router'
|
||||
import {storeToRefs} from 'pinia'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
import {unrefElement, useMediaQuery} from '@vueuse/core'
|
||||
import {unrefElement, useDebounceFn, useElementSize, useIntersectionObserver, useMediaQuery, useMutationObserver} from '@vueuse/core'
|
||||
import {klona} from 'klona/lite'
|
||||
import {eventToHotkeyString} from '@github/hotkey'
|
||||
|
||||
@@ -721,6 +738,7 @@ onMounted(() => {
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('keydown', saveTaskViaHotkey)
|
||||
debouncedMutationHandler.cancel()
|
||||
})
|
||||
|
||||
onBeforeRouteLeave(async () => {
|
||||
@@ -795,6 +813,82 @@ async function scrollToHeading() {
|
||||
scrollIntoView(unrefElement(heading))
|
||||
}
|
||||
|
||||
const taskViewContainer = ref<HTMLElement | null>(null)
|
||||
const scrollContainer = ref<HTMLElement | null>(null)
|
||||
const contentBottomMarker = ref<HTMLElement | null>(null)
|
||||
const bottomMarkerVisible = ref(true)
|
||||
const isScrollable = ref(false)
|
||||
|
||||
function resolveScrollContainer() {
|
||||
let el = taskViewContainer.value
|
||||
|
||||
while (el) {
|
||||
const overflowY = getComputedStyle(el).overflowY
|
||||
if (['auto', 'scroll', 'overlay'].includes(overflowY)) {
|
||||
scrollContainer.value = el
|
||||
return
|
||||
}
|
||||
el = el.parentElement
|
||||
}
|
||||
|
||||
scrollContainer.value = (document.scrollingElement as HTMLElement | null) ?? document.documentElement
|
||||
}
|
||||
|
||||
function updateScrollable() {
|
||||
const scroller = scrollContainer.value
|
||||
if (!scroller) {
|
||||
isScrollable.value = false
|
||||
return
|
||||
}
|
||||
|
||||
isScrollable.value = scroller.scrollHeight > scroller.clientHeight + 1
|
||||
}
|
||||
|
||||
const showScrollToCommentsButton = computed(() => {
|
||||
return isScrollable.value && !bottomMarkerVisible.value
|
||||
})
|
||||
|
||||
function scrollToBottom() {
|
||||
if (!contentBottomMarker.value) {
|
||||
return
|
||||
}
|
||||
|
||||
contentBottomMarker.value.scrollIntoView({
|
||||
behavior: 'smooth',
|
||||
block: 'end',
|
||||
inline: 'nearest',
|
||||
})
|
||||
}
|
||||
|
||||
useIntersectionObserver(
|
||||
contentBottomMarker,
|
||||
([entry]) => {
|
||||
bottomMarkerVisible.value = entry?.isIntersecting ?? true
|
||||
},
|
||||
{threshold: 0.1},
|
||||
)
|
||||
|
||||
const debouncedMutationHandler = useDebounceFn(async () => {
|
||||
await nextTick()
|
||||
resolveScrollContainer()
|
||||
updateScrollable()
|
||||
}, 100)
|
||||
|
||||
useMutationObserver(
|
||||
taskViewContainer,
|
||||
debouncedMutationHandler,
|
||||
{subtree: true, childList: true},
|
||||
)
|
||||
|
||||
const {height: scrollContainerHeight} = useElementSize(scrollContainer)
|
||||
watch(scrollContainerHeight, () => updateScrollable())
|
||||
|
||||
onMounted(async () => {
|
||||
await nextTick()
|
||||
resolveScrollContainer()
|
||||
updateScrollable()
|
||||
})
|
||||
|
||||
const taskService = shallowReactive(new TaskService())
|
||||
|
||||
// load task
|
||||
@@ -831,6 +925,8 @@ watch(
|
||||
} finally {
|
||||
await nextTick()
|
||||
scrollToHeading()
|
||||
resolveScrollContainer()
|
||||
updateScrollable()
|
||||
visible.value = true
|
||||
}
|
||||
}, {immediate: true})
|
||||
@@ -1258,4 +1354,42 @@ h3 .button {
|
||||
margin: .5rem 0;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.scroll-to-comments-button {
|
||||
position: fixed;
|
||||
// Position above the keyboard shortcuts button (which is at bottom: calc(1rem - 4px))
|
||||
inset-block-end: 2.5rem;
|
||||
inset-inline-end: .75rem;
|
||||
z-index: 10;
|
||||
inline-size: 2rem;
|
||||
block-size: 2rem;
|
||||
border-radius: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
background-color: var(--site-background);
|
||||
border: 1px solid var(--grey-300);
|
||||
color: var(--grey-500);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
||||
transition: all $transition;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--grey-100);
|
||||
color: var(--grey-700);
|
||||
}
|
||||
|
||||
@media screen and (max-width: $tablet) {
|
||||
// Hide on mobile since keyboard shortcuts button is also hidden
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
// global style to override position when the modal task detail is active
|
||||
.modal-content .scroll-to-comments-button {
|
||||
inset-block-end: .75rem;
|
||||
inset-inline-end: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1057,6 +1057,154 @@ test.describe('Task', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Scroll to bottom button', () => {
|
||||
test('Shows scroll-to-bottom button when content is long and hides when at bottom', async ({authenticatedPage: page}) => {
|
||||
// Create a task with a very long description to ensure scrollable content
|
||||
const longDescription = `
|
||||
<h1>Introduction</h1>
|
||||
<p>This is a very long description to test the scroll-to-bottom button functionality.</p>
|
||||
${Array(30).fill('<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris.</p>').join('\n')}
|
||||
<h2>Conclusion</h2>
|
||||
<p>End of the long description.</p>
|
||||
`
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: longDescription,
|
||||
})
|
||||
|
||||
// Set viewport to ensure content is scrollable
|
||||
await page.setViewportSize({width: 1280, height: 800})
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Scroll to top and wait for scroll to complete
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
await page.waitForFunction(() => window.scrollY <= 5)
|
||||
|
||||
// The scroll-to-bottom button should be visible when not at bottom
|
||||
const scrollButton = page.locator('.scroll-to-comments-button')
|
||||
await expect(scrollButton).toBeVisible({timeout: 5000})
|
||||
|
||||
// Click the button to scroll to bottom
|
||||
await scrollButton.click()
|
||||
|
||||
// Wait for the bottom marker to be in or near the viewport (within 50px tolerance)
|
||||
const bottomMarker = page.locator('.content-bottom-marker')
|
||||
await expect(async () => {
|
||||
const markerTop = await bottomMarker.evaluate((el) => el.getBoundingClientRect().top)
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
||||
expect(markerTop).toBeLessThanOrEqual(viewportHeight + 50)
|
||||
}).toPass({timeout: 5000})
|
||||
|
||||
// The button should be hidden when at the bottom
|
||||
await expect(scrollButton).not.toBeVisible({timeout: 5000})
|
||||
})
|
||||
|
||||
test('Shows scroll-to-bottom button with long comments', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Short description',
|
||||
})
|
||||
|
||||
// Create a long comment to ensure scrollable content
|
||||
const longComment = `
|
||||
# Code Review Summary
|
||||
|
||||
This is a very long comment that should make the page scrollable.
|
||||
|
||||
## Changes Overview
|
||||
|
||||
${Array(20).fill('- Lorem ipsum dolor sit amet, consectetur adipiscing elit').join('\n')}
|
||||
|
||||
## Detailed Analysis
|
||||
|
||||
${Array(10).fill('The implementation looks good overall. Here are some specific points to consider:\n\n1. Performance implications\n2. Security considerations\n3. Code maintainability\n\n').join('\n')}
|
||||
|
||||
## Conclusion
|
||||
|
||||
Everything looks good!
|
||||
`
|
||||
await TaskCommentFactory.create(1, {
|
||||
task_id: tasks[0].id,
|
||||
comment: longComment,
|
||||
})
|
||||
|
||||
// Set viewport to ensure content is scrollable
|
||||
await page.setViewportSize({width: 1280, height: 800})
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Scroll to top and wait for scroll to complete
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
await page.waitForFunction(() => window.scrollY <= 5)
|
||||
|
||||
// The scroll-to-bottom button should be visible
|
||||
const scrollButton = page.locator('.scroll-to-comments-button')
|
||||
await expect(scrollButton).toBeVisible({timeout: 5000})
|
||||
|
||||
// Click the button to scroll to bottom
|
||||
await scrollButton.click()
|
||||
|
||||
// Wait for the bottom marker to be in or near the viewport (within 50px tolerance)
|
||||
const bottomMarker = page.locator('.content-bottom-marker')
|
||||
await expect(async () => {
|
||||
const markerTop = await bottomMarker.evaluate((el) => el.getBoundingClientRect().top)
|
||||
const viewportHeight = await page.evaluate(() => window.innerHeight)
|
||||
expect(markerTop).toBeLessThanOrEqual(viewportHeight + 50)
|
||||
}).toPass({timeout: 5000})
|
||||
|
||||
// The button should be hidden when at the bottom
|
||||
await expect(scrollButton).not.toBeVisible({timeout: 5000})
|
||||
})
|
||||
|
||||
test('Does not show scroll-to-bottom button when already at bottom', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: 'Short description',
|
||||
})
|
||||
|
||||
// Set viewport
|
||||
await page.setViewportSize({width: 1280, height: 800})
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Scroll to bottom of page and wait for scroll to complete
|
||||
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight))
|
||||
await page.waitForFunction(() => {
|
||||
const scrollTop = window.scrollY || document.documentElement.scrollTop
|
||||
const scrollHeight = document.documentElement.scrollHeight
|
||||
const clientHeight = document.documentElement.clientHeight
|
||||
return scrollTop + clientHeight >= scrollHeight - 5
|
||||
})
|
||||
|
||||
// The scroll-to-bottom button should not be visible when already at bottom
|
||||
const scrollButton = page.locator('.scroll-to-comments-button')
|
||||
await expect(scrollButton).not.toBeVisible({timeout: 3000})
|
||||
})
|
||||
|
||||
test('Does not show scroll-to-bottom button on mobile', async ({authenticatedPage: page}) => {
|
||||
// Create a task with long content
|
||||
const longDescription = Array(30).fill('<p>Lorem ipsum dolor sit amet, consectetur adipiscing elit.</p>').join('\n')
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
id: 1,
|
||||
description: longDescription,
|
||||
})
|
||||
|
||||
// Set mobile viewport
|
||||
await page.setViewportSize({width: 375, height: 667})
|
||||
await page.goto(`/tasks/${tasks[0].id}`)
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Scroll to top and wait for scroll to complete
|
||||
await page.evaluate(() => window.scrollTo(0, 0))
|
||||
await page.waitForFunction(() => window.scrollY <= 5)
|
||||
|
||||
// The scroll-to-bottom button should be hidden on mobile (CSS hides it)
|
||||
const scrollButton = page.locator('.scroll-to-comments-button')
|
||||
await expect(scrollButton).not.toBeVisible({timeout: 3000})
|
||||
})
|
||||
})
|
||||
|
||||
test.describe('Link functionality in description editor', () => {
|
||||
test('Should show URL input when clicking link button without scroll', async ({authenticatedPage: page}) => {
|
||||
const tasks = await TaskFactory.create(1, {
|
||||
|
||||
Reference in New Issue
Block a user