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:
kolaente
2025-12-16 23:06:47 +01:00
committed by GitHub
parent 770e4cbe66
commit 4284673bf7
4 changed files with 285 additions and 2 deletions

2
.gitignore vendored
View File

@@ -46,4 +46,4 @@ devenv.local.nix
/.claude/settings.local.json
PLAN.md
/.crush/
/.playwright-mcp

View File

@@ -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",

View File

@@ -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>

View File

@@ -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, {