feat: show task card preview when hovering over task title in list and table view (#1863)

This commit is contained in:
kolaente 2025-11-22 12:10:58 +01:00 committed by GitHub
parent 953623c132
commit 2bc2311212
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 391 additions and 66 deletions

View File

@ -207,9 +207,11 @@
/>
</td>
<td v-if="activeColumns.title">
<RouterLink :to="taskDetailRoutes[t.id]">
{{ t.title }}
</RouterLink>
<TaskGlanceTooltip :task="t">
<RouterLink :to="taskDetailRoutes[t.id]">
{{ t.title }}
</RouterLink>
</TaskGlanceTooltip>
</td>
<td v-if="activeColumns.priority">
<PriorityLabel
@ -235,13 +237,7 @@
:date="t.dueDate"
/>
<td v-if="activeColumns.commentCount">
<span
v-if="t.commentCount && t.commentCount > 0"
class="comment-badge"
>
<Icon icon="comment" />
{{ t.commentCount }}
</span>
<CommentCount :task="t" />
</td>
<DateTableCell
v-if="activeColumns.startDate"
@ -298,7 +294,9 @@ import Done from '@/components/misc/Done.vue'
import User from '@/components/misc/User.vue'
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
import Labels from '@/components/tasks/partials/Labels.vue'
import TaskGlanceTooltip from '@/components/tasks/partials/TaskGlanceTooltip.vue'
import DateTableCell from '@/components/tasks/partials/DateTableCell.vue'
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
import FancyCheckbox from '@/components/input/FancyCheckbox.vue'
import Sort from '@/components/tasks/partials/Sort.vue'
import FilterPopup from '@/components/project/partials/FilterPopup.vue'

View File

@ -0,0 +1,46 @@
<template>
<span
v-if="task.commentCount && task.commentCount > 0"
v-tooltip="tooltip"
class="comment-count"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
</span>
</template>
<script setup lang="ts">
import {computed} from 'vue'
import {useI18n} from 'vue-i18n'
import type {ITask} from '@/modelTypes/ITask'
const props = defineProps<{
task: ITask
}>()
const {t} = useI18n({useScope: 'global'})
const tooltip = computed(() => t('task.attributes.comment', props.task.commentCount))
</script>
<style scoped lang="scss">
.comment-count {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--grey-500);
.comment-count-badge {
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
}
&:hover {
color: var(--primary);
}
}
</style>

View File

@ -84,16 +84,12 @@
v-if="task.attachments.length > 0"
class="icon"
>
<Icon icon="paperclip" />
</span>
<span
v-if="task.commentCount && task.commentCount > 0"
v-tooltip="$t('task.attributes.comment', task.commentCount)"
class="project-task-icon comment-count-icon"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
<Icon icon="paperclip" />
</span>
<CommentCount
:task="task"
class="project-task-icon"
/>
<span
v-if="!isEditorContentEmpty(task.description)"
class="icon"
@ -122,6 +118,7 @@ import ProgressBar from '@/components/misc/ProgressBar.vue'
import Done from '@/components/misc/Done.vue'
import Labels from '@/components/tasks/partials/Labels.vue'
import ChecklistSummary from './ChecklistSummary.vue'
import CommentCount from './CommentCount.vue'
import {getHexColor} from '@/models/task'
import type {ITask} from '@/modelTypes/ITask'
@ -405,21 +402,10 @@ $task-background: var(--white);
block-size: 0.5rem;
}
.comment-count-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--grey-500);
:deep(.comment-count) {
background: var(--grey-100);
border-radius: $radius;
padding: 0.25rem;
padding: 0.25rem;
margin-inline-end: .25rem;
.comment-count-badge {
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
}
}
</style>

View File

@ -49,14 +49,16 @@
class="pis-2 mie-1"
/>
<RouterLink
ref="taskLinkRef"
:to="taskDetailRoute"
class="task-link"
tabindex="-1"
>
{{ task.title }}
</RouterLink>
<TaskGlanceTooltip :task="task">
<RouterLink
ref="taskLinkRef"
:to="taskDetailRoute"
class="task-link"
tabindex="-1"
>
{{ task.title }}
</RouterLink>
</TaskGlanceTooltip>
</span>
<Labels
@ -120,14 +122,10 @@
>
<Icon icon="history" />
</span>
<span
v-if="task.commentCount && task.commentCount > 0"
class="project-task-icon comment-count-icon"
:title="`${task.commentCount} ${task.commentCount === 1 ? 'comment' : 'comments'}`"
>
<Icon :icon="['far', 'comments']" />
<span class="comment-count-badge">{{ task.commentCount }}</span>
</span>
<CommentCount
:task="task"
class="project-task-icon"
/>
</span>
<ChecklistSummary :task="task" />
@ -198,8 +196,10 @@ import type {ITask} from '@/modelTypes/ITask'
import PriorityLabel from '@/components/tasks/partials/PriorityLabel.vue'
import Labels from '@/components/tasks/partials/Labels.vue'
import TaskGlanceTooltip from '@/components/tasks/partials/TaskGlanceTooltip.vue'
import DeferTask from '@/components/tasks/partials/DeferTask.vue'
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
import ProgressBar from '@/components/misc/ProgressBar.vue'
import BaseButton from '@/components/base/BaseButton.vue'
@ -573,22 +573,4 @@ defineExpose({
border: 1px solid var(--grey-200);
}
}
.comment-count-icon {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--grey-500);
.comment-count-badge {
font-weight: 600;
font-size: 0.75rem;
line-height: 1;
}
&:hover {
color: var(--primary);
}
}
</style>

View File

@ -0,0 +1,313 @@
<template>
<span
ref="triggerRef"
class="task-glance-trigger"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
<slot />
</span>
<Teleport to="body">
<CustomTransition name="fade">
<div
v-if="showTooltip"
ref="tooltipRef"
class="task-glance-tooltip"
role="tooltip"
>
<div class="task-glance-content">
<div class="task-glance-header">
<div class="task-glance-title-section">
<span class="task-identifier">{{ taskIdentifier }}</span>
<span class="task-title">{{ task.title }}</span>
</div>
<div class="task-glance-indicators">
<span
v-if="task.attachments.length > 0"
class="task-glance-icon"
>
<Icon icon="paperclip" />
</span>
<CommentCount
:task="task"
class="task-glance-icon"
/>
</div>
</div>
<div
v-if="descriptionPreview"
class="task-glance-description"
>
{{ descriptionPreview }}
</div>
<ChecklistSummary
:task="task"
class="task-glance-checklist-summary"
/>
<Labels
v-if="task.labels.length > 0"
:labels="task.labels"
class="task-glance-labels"
/>
<div
v-if="task.dueDate"
class="task-glance-due"
>
<Icon icon="calendar" />
<span>{{ $t('task.detail.due', {at: formatDisplayDate(task.dueDate)}) }}</span>
</div>
<div class="task-glance-meta">
<div class="task-glance-created">
<i18n-t
keypath="task.detail.created"
scope="global"
>
<span>{{ formatDisplayDate(task.created) }}</span>
{{ getDisplayName(task.createdBy) }}
</i18n-t>
</div>
</div>
</div>
</div>
</CustomTransition>
</Teleport>
</template>
<script setup lang="ts">
import {ref, computed, onUnmounted, nextTick} from 'vue'
import {computePosition, flip, offset, shift} from '@floating-ui/dom'
import type {ITask} from '@/modelTypes/ITask'
import {getTaskIdentifier} from '@/models/task'
import {formatDisplayDate} from '@/helpers/time/formatDate'
import {getDisplayName} from '@/models/user'
import {isEditorContentEmpty} from '@/helpers/editorContentEmpty'
import Labels from '@/components/tasks/partials/Labels.vue'
import ChecklistSummary from '@/components/tasks/partials/ChecklistSummary.vue'
import CommentCount from '@/components/tasks/partials/CommentCount.vue'
import CustomTransition from '@/components/misc/CustomTransition.vue'
const props = defineProps<{
task: ITask
}>()
const HOVER_DELAY = 1000 // 1 second
const MAX_DESCRIPTION_LENGTH = 150
const triggerRef = ref<HTMLElement | null>(null)
const tooltipRef = ref<HTMLElement | null>(null)
const showTooltip = ref(false)
let hoverTimeout: ReturnType<typeof setTimeout> | null = null
const taskIdentifier = computed(() => getTaskIdentifier(props.task))
const descriptionPreview = computed(() => {
if (isEditorContentEmpty(props.task.description)) {
return ''
}
// Create a temporary div to extract plain text from HTML
const tempDiv = document.createElement('div')
tempDiv.innerHTML = props.task.description
const plainText = tempDiv.textContent || tempDiv.innerText || ''
const trimmedText = plainText.trim()
if (trimmedText.length <= MAX_DESCRIPTION_LENGTH) {
return trimmedText
}
return trimmedText.substring(0, MAX_DESCRIPTION_LENGTH) + '…'
})
async function updatePosition() {
if (!triggerRef.value || !tooltipRef.value) {
return
}
await nextTick()
const {x, y} = await computePosition(triggerRef.value, tooltipRef.value, {
strategy: 'absolute',
placement: 'top',
middleware: [
offset(8),
flip({
fallbackPlacements: ['bottom', 'top-start', 'top-end', 'bottom-start', 'bottom-end'],
padding: 8,
}),
shift({padding: 8}),
],
})
// Set position directly on the element
if (tooltipRef.value) {
tooltipRef.value.style.left = `${x}px`
tooltipRef.value.style.top = `${y}px`
}
}
function handleMouseEnter() {
// Clear any existing timeout
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
// Set timeout to show tooltip after 1 second
hoverTimeout = setTimeout(async () => {
showTooltip.value = true
// Wait for the tooltip to be rendered in the DOM
await nextTick()
await updatePosition()
}, HOVER_DELAY)
}
function handleMouseLeave() {
// Clear timeout if user moves away before 1 second
if (hoverTimeout) {
clearTimeout(hoverTimeout)
hoverTimeout = null
}
// Hide tooltip
showTooltip.value = false
}
// Cleanup on unmount
onUnmounted(() => {
if (hoverTimeout) {
clearTimeout(hoverTimeout)
}
})
</script>
<style lang="scss" scoped>
.task-glance-trigger {
display: inline;
}
.task-glance-tooltip {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
z-index: 9999;
max-inline-size: 400px;
background: var(--white);
border: 1px solid var(--grey-200);
border-radius: $radius;
box-shadow: var(--shadow-lg);
pointer-events: none;
}
.task-glance-content {
padding: 0.75rem;
display: flex;
flex-direction: column;
gap: 0.5rem;
font-size: 0.875rem;
color: var(--text);
}
.task-glance-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.task-glance-title-section {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-inline-size: 0; /* Allow text to wrap */
}
.task-identifier {
font-size: 0.75rem;
color: var(--grey-500);
font-weight: 600;
}
.task-title {
font-weight: 600;
color: var(--text);
word-wrap: break-word;
}
.task-glance-description {
color: var(--grey-700);
font-size: 0.875rem;
line-height: 1.4;
word-wrap: break-word;
white-space: pre-wrap;
}
.task-glance-labels {
margin: 0;
}
.task-glance-due {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--grey-700);
.icon {
inline-size: 1rem;
block-size: 1rem;
}
}
.task-glance-meta {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding-block-start: 0.25rem;
border-block-start: 1px solid var(--grey-200);
font-size: 0.8rem;
color: var(--grey-600);
}
.task-glance-created {
display: flex;
align-items: center;
gap: 0.5rem;
}
.task-glance-indicators {
display: flex;
align-items: center;
flex-shrink: 0; /* Prevent icons from shrinking */
:deep(.checklist-summary) {
padding-inline-end: 6px;
&:not(:last-child) {
padding-inline-end: 8px;
}
}
}
.task-glance-icon {
color: var(--grey-500);
font-size: 0.875rem;
display: inline-flex;
align-items: center;
margin-inline-end: 6px;
&:not(:last-of-type) {
margin-inline-end: 8px;
}
}
.task-glance-checklist-summary {
margin-block-start: -0.25rem;
}
</style>