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:
parent
770e4cbe66
commit
4284673bf7
|
|
@ -46,4 +46,4 @@ devenv.local.nix
|
||||||
/.claude/settings.local.json
|
/.claude/settings.local.json
|
||||||
PLAN.md
|
PLAN.md
|
||||||
/.crush/
|
/.crush/
|
||||||
|
/.playwright-mcp
|
||||||
|
|
|
||||||
|
|
@ -832,6 +832,7 @@
|
||||||
"back": "Back to project",
|
"back": "Back to project",
|
||||||
"due": "Due {at}",
|
"due": "Due {at}",
|
||||||
"closePopup": "Close popup",
|
"closePopup": "Close popup",
|
||||||
|
"scrollToBottom": "Scroll to bottom",
|
||||||
"organization": "Organization",
|
"organization": "Organization",
|
||||||
"management": "Management",
|
"management": "Management",
|
||||||
"dateAndTime": "Date and time",
|
"dateAndTime": "Date and time",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div
|
||||||
|
ref="taskViewContainer"
|
||||||
class="loader-container task-view-container"
|
class="loader-container task-view-container"
|
||||||
:class="{
|
:class="{
|
||||||
'is-loading': taskService.loading || !visible,
|
'is-loading': taskService.loading || !visible,
|
||||||
|
|
@ -407,6 +408,12 @@
|
||||||
:project-id="task.projectId"
|
:project-id="task.projectId"
|
||||||
:initial-comments="task.comments"
|
:initial-comments="task.comments"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<!-- Marker element for scroll-to-bottom button visibility -->
|
||||||
|
<div
|
||||||
|
ref="contentBottomMarker"
|
||||||
|
class="content-bottom-marker"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Task Actions -->
|
<!-- Task Actions -->
|
||||||
|
|
@ -575,6 +582,16 @@
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<Modal
|
||||||
:enabled="showDeleteModal"
|
:enabled="showDeleteModal"
|
||||||
@close="showDeleteModal = false"
|
@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 {useRouter, useRoute, type RouteLocation, onBeforeRouteLeave} from 'vue-router'
|
||||||
import {storeToRefs} from 'pinia'
|
import {storeToRefs} from 'pinia'
|
||||||
import {useI18n} from 'vue-i18n'
|
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 {klona} from 'klona/lite'
|
||||||
import {eventToHotkeyString} from '@github/hotkey'
|
import {eventToHotkeyString} from '@github/hotkey'
|
||||||
|
|
||||||
|
|
@ -721,6 +738,7 @@ onMounted(() => {
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', saveTaskViaHotkey)
|
document.removeEventListener('keydown', saveTaskViaHotkey)
|
||||||
|
debouncedMutationHandler.cancel()
|
||||||
})
|
})
|
||||||
|
|
||||||
onBeforeRouteLeave(async () => {
|
onBeforeRouteLeave(async () => {
|
||||||
|
|
@ -795,6 +813,82 @@ async function scrollToHeading() {
|
||||||
scrollIntoView(unrefElement(heading))
|
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())
|
const taskService = shallowReactive(new TaskService())
|
||||||
|
|
||||||
// load task
|
// load task
|
||||||
|
|
@ -831,6 +925,8 @@ watch(
|
||||||
} finally {
|
} finally {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
scrollToHeading()
|
scrollToHeading()
|
||||||
|
resolveScrollContainer()
|
||||||
|
updateScrollable()
|
||||||
visible.value = true
|
visible.value = true
|
||||||
}
|
}
|
||||||
}, {immediate: true})
|
}, {immediate: true})
|
||||||
|
|
@ -1258,4 +1354,42 @@ h3 .button {
|
||||||
margin: .5rem 0;
|
margin: .5rem 0;
|
||||||
display: inline-block;
|
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>
|
</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.describe('Link functionality in description editor', () => {
|
||||||
test('Should show URL input when clicking link button without scroll', async ({authenticatedPage: page}) => {
|
test('Should show URL input when clicking link button without scroll', async ({authenticatedPage: page}) => {
|
||||||
const tasks = await TaskFactory.create(1, {
|
const tasks = await TaskFactory.create(1, {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue