From cefb1f2113708b7c17055b930fd3dd80cf8485fe Mon Sep 17 00:00:00 2001 From: Callum Alpass Date: Sat, 5 Jul 2025 11:09:40 +1000 Subject: [PATCH] fix: enhance project link clickability and accessibility in TaskCard - Add proper project link rendering with click handlers for wikilink projects - Enhance CSS styling with better focus states and z-index positioning - Add keyboard support (Enter/Space) for accessibility - Add error handling for failed file operations - Improve visual feedback with hover states and focus indicators - Add comprehensive test coverage for project link functionality - Ensure proper DOM structure with inline-block display and relative positioning --- src/ui/TaskCard.ts | 162 ++++++++++++++++++++++++++++++--- styles/task-card-bem.css | 36 ++++++++ tests/unit/ui/TaskCard.test.ts | 76 ++++++++++++++++ 3 files changed, 259 insertions(+), 15 deletions(-) diff --git a/src/ui/TaskCard.ts b/src/ui/TaskCard.ts index 2afedd8e..fd0d25b3 100644 --- a/src/ui/TaskCard.ts +++ b/src/ui/TaskCard.ts @@ -429,6 +429,13 @@ export function createTaskCard(task: TaskInfo, plugin: TaskNotesPlugin, options: metadataElements.push(contextsSpan); } + // Projects (if has projects) + if (task.projects && task.projects.length > 0) { + const projectsSpan = metadataLine.createEl('span'); + renderProjectLinks(projectsSpan, task.projects, plugin); + metadataElements.push(projectsSpan); + } + // Time tracking (if has time estimate or logged time) const timeSpent = calculateTotalTimeSpent(task.timeEntries || []); if (task.timeEstimate || timeSpent > 0) { @@ -834,12 +841,16 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas // Update metadata line const metadataLine = element.querySelector('.task-card__metadata') as HTMLElement; if (metadataLine) { - const metadataItems: string[] = []; + // Clear the metadata line and rebuild with DOM elements to support project links + metadataLine.innerHTML = ''; + const metadataElements: HTMLElement[] = []; // Recurrence info (if recurring) if (task.recurrence) { const frequencyDisplay = getRecurrenceDisplayText(task.recurrence); - metadataItems.push(`Recurring: ${frequencyDisplay}`); + const recurringSpan = metadataLine.createEl('span'); + recurringSpan.textContent = `Recurring: ${frequencyDisplay}`; + metadataElements.push(recurringSpan); } // Due date (if has due date) @@ -847,6 +858,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas const isDueToday = isTodayTimeAware(task.due); const isDueOverdue = isOverdueTimeAware(task.due); + let dueDateText = ''; if (isDueToday) { // For today, show time if available const timeDisplay = formatDateTimeForDisplay(task.due, { @@ -855,9 +867,9 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas showTime: true }); if (timeDisplay.trim() === '') { - metadataItems.push('Due: Today'); + dueDateText = 'Due: Today'; } else { - metadataItems.push(`Due: Today at ${timeDisplay}`); + dueDateText = `Due: Today at ${timeDisplay}`; } } else if (isDueOverdue) { // For overdue, show date and time if available @@ -866,7 +878,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas timeFormat: 'h:mm a', showTime: true }); - metadataItems.push(`Due: ${display} (overdue)`); + dueDateText = `Due: ${display} (overdue)`; } else { // For future dates, show date and time if available const display = formatDateTimeForDisplay(task.due, { @@ -874,8 +886,14 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas timeFormat: 'h:mm a', showTime: true }); - metadataItems.push(`Due: ${display}`); + dueDateText = `Due: ${display}`; } + + const dueDateSpan = metadataLine.createEl('span', { + cls: 'task-card__metadata-date task-card__metadata-date--due', + text: dueDateText + }); + metadataElements.push(dueDateSpan); } // Scheduled date (if has scheduled date) @@ -883,6 +901,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas const isScheduledToday = isTodayTimeAware(task.scheduled); const isScheduledPast = isOverdueTimeAware(task.scheduled); + let scheduledDateText = ''; if (isScheduledToday) { // For today, show time if available const timeDisplay = formatDateTimeForDisplay(task.scheduled, { @@ -891,9 +910,9 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas showTime: true }); if (timeDisplay.trim() === '') { - metadataItems.push('Scheduled: Today'); + scheduledDateText = 'Scheduled: Today'; } else { - metadataItems.push(`Scheduled: Today at ${timeDisplay}`); + scheduledDateText = `Scheduled: Today at ${timeDisplay}`; } } else if (isScheduledPast) { // For past dates, show date and time if available @@ -902,7 +921,7 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas timeFormat: 'h:mm a', showTime: true }); - metadataItems.push(`Scheduled: ${display} (past)`); + scheduledDateText = `Scheduled: ${display} (past)`; } else { // For future dates, show date and time if available const display = formatDateTimeForDisplay(task.scheduled, { @@ -910,13 +929,28 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas timeFormat: 'h:mm a', showTime: true }); - metadataItems.push(`Scheduled: ${display}`); + scheduledDateText = `Scheduled: ${display}`; } + + const scheduledSpan = metadataLine.createEl('span', { + cls: 'task-card__metadata-date task-card__metadata-date--scheduled', + text: scheduledDateText + }); + metadataElements.push(scheduledSpan); } // Contexts (if has contexts) if (task.contexts && task.contexts.length > 0) { - metadataItems.push(`@${task.contexts.join(', @')}`); + const contextsSpan = metadataLine.createEl('span'); + contextsSpan.textContent = `@${task.contexts.join(', @')}`; + metadataElements.push(contextsSpan); + } + + // Projects (if has projects) - use specialized rendering for links + if (task.projects && task.projects.length > 0) { + const projectsSpan = metadataLine.createEl('span'); + renderProjectLinks(projectsSpan, task.projects, plugin); + metadataElements.push(projectsSpan); } // Time tracking (if has time estimate or logged time) @@ -929,12 +963,22 @@ export function updateTaskCard(element: HTMLElement, task: TaskInfo, plugin: Tas if (task.timeEstimate) { timeInfo.push(`${plugin.formatTime(task.timeEstimate)} estimated`); } - metadataItems.push(timeInfo.join(', ')); + const timeSpan = metadataLine.createEl('span'); + timeSpan.textContent = timeInfo.join(', '); + metadataElements.push(timeSpan); } - // Update metadata line - if (metadataItems.length > 0) { - metadataLine.textContent = metadataItems.join(' • '); + // Add separators between metadata elements + if (metadataElements.length > 0) { + // Insert separators between elements + for (let i = 1; i < metadataElements.length; i++) { + const separator = metadataLine.createEl('span', { + cls: 'task-card__metadata-separator', + text: ' • ' + }); + // Insert separator before each element (except first) + metadataElements[i].insertAdjacentElement('beforebegin', separator); + } metadataLine.style.display = ''; } else { metadataLine.style.display = 'none'; @@ -1036,3 +1080,91 @@ export async function showDeleteConfirmationModal(task: TaskInfo, plugin: TaskNo modal.open(); }); } + +/** + * Check if a project string is in wikilink format [[Note Name]] + */ +function isWikilinkProject(project: string): boolean { + return project.startsWith('[[') && project.endsWith(']]'); +} + +/** + * Render project links in a container element, handling both plain text and wikilink projects + */ +function renderProjectLinks(container: HTMLElement, projects: string[], plugin: TaskNotesPlugin): void { + container.innerHTML = ''; + + projects.forEach((project, index) => { + if (index > 0) { + const separator = document.createTextNode(', '); + container.appendChild(separator); + } + + const plusText = document.createTextNode('+'); + container.appendChild(plusText); + + if (isWikilinkProject(project)) { + // Extract the note name from [[Note Name]] + const noteName = project.slice(2, -2); + + // Create a clickable link + const linkEl = container.createEl('a', { + cls: 'task-card__project-link internal-link', + text: noteName, + attr: { + 'data-href': noteName, + 'role': 'button', + 'tabindex': '0' + } + }); + + // Add click handler to open the note + linkEl.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + try { + // Resolve the link to get the actual file + const file = plugin.app.metadataCache.getFirstLinkpathDest(noteName, ''); + if (file instanceof TFile) { + // Open the file in the current leaf + await plugin.app.workspace.getLeaf(false).openFile(file); + } else { + // File not found, show notice + new Notice(`Note "${noteName}" not found`); + } + } catch (error) { + console.error('Error opening project link:', error); + new Notice(`Failed to open note "${noteName}"`); + } + }); + + // Add keyboard support for accessibility + linkEl.addEventListener('keydown', (e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + linkEl.click(); + } + }); + + // Add hover preview for the project link + linkEl.addEventListener('mouseover', (event) => { + const file = plugin.app.metadataCache.getFirstLinkpathDest(noteName, ''); + if (file instanceof TFile) { + plugin.app.workspace.trigger('hover-link', { + event, + source: 'tasknotes-project-link', + hoverParent: container, + targetEl: linkEl, + linktext: noteName, + sourcePath: file.path + }); + } + }); + } else { + // Plain text project + const textNode = document.createTextNode(project); + container.appendChild(textNode); + } + }); +} diff --git a/styles/task-card-bem.css b/styles/task-card-bem.css index 6c8bfd5f..1a68e5da 100644 --- a/styles/task-card-bem.css +++ b/styles/task-card-bem.css @@ -321,6 +321,42 @@ color: var(--color-blue); } +/* Project links */ +.tasknotes-plugin .task-card__project-link { + color: var(--interactive-accent); + text-decoration: none; + transition: all 0.2s ease; + border-radius: var(--radius-s); + padding: 1px 2px; + margin: -1px -2px; + cursor: pointer; + display: inline-block; + position: relative; + z-index: 1; +} + +.tasknotes-plugin .task-card__project-link:hover, +.tasknotes-plugin .task-card__project-link:focus { + background: var(--background-modifier-hover); + text-decoration: underline; + color: var(--interactive-accent-hover); + outline: none; +} + +.tasknotes-plugin .task-card__project-link:focus { + box-shadow: 0 0 0 2px var(--interactive-accent); +} + +.tasknotes-plugin .task-card__project-link.internal-link { + /* Ensure consistent styling with Obsidian's internal links */ + color: var(--link-color); +} + +.tasknotes-plugin .task-card__project-link.internal-link:hover, +.tasknotes-plugin .task-card__project-link.internal-link:focus { + color: var(--link-color-hover); +} + /* ================================================================= TASKCARD MODIFIERS (BEM --modifier) diff --git a/tests/unit/ui/TaskCard.test.ts b/tests/unit/ui/TaskCard.test.ts index 3f8ac817..c363e002 100644 --- a/tests/unit/ui/TaskCard.test.ts +++ b/tests/unit/ui/TaskCard.test.ts @@ -134,6 +134,8 @@ describe('TaskCard Component', () => { openFile: jest.fn() }); mockApp.workspace.trigger = jest.fn(); + mockApp.metadataCache = mockApp.metadataCache || {}; + mockApp.metadataCache.getFirstLinkpathDest = jest.fn(); // Mock console methods jest.spyOn(console, 'error').mockImplementation(() => {}); @@ -268,6 +270,80 @@ describe('TaskCard Component', () => { expect(metadataLine?.textContent).toContain('60m estimated'); }); + it('should create clickable project links for wikilink projects', () => { + const task = TaskFactory.createTask({ + projects: ['[[Project A]]', '[[Project B]]', 'regular-project'] + }); + + const card = createTaskCard(task, mockPlugin); + const metadataLine = card.querySelector('.task-card__metadata'); + + // Check that project links are rendered + expect(metadataLine?.textContent).toContain('+Project A'); + expect(metadataLine?.textContent).toContain('+Project B'); + expect(metadataLine?.textContent).toContain('+regular-project'); + + // Check that wikilink projects have clickable links + const projectLinks = card.querySelectorAll('.task-card__project-link'); + expect(projectLinks.length).toBe(2); // Only wikilink projects should have clickable links + + // Check link properties + expect(projectLinks[0].textContent).toBe('Project A'); + expect(projectLinks[0].getAttribute('data-href')).toBe('Project A'); + expect(projectLinks[0].classList.contains('internal-link')).toBe(true); + + expect(projectLinks[1].textContent).toBe('Project B'); + expect(projectLinks[1].getAttribute('data-href')).toBe('Project B'); + expect(projectLinks[1].classList.contains('internal-link')).toBe(true); + }); + + it('should handle project link clicks and open files', async () => { + const task = TaskFactory.createTask({ + projects: ['[[Test Project]]'] + }); + + const mockFile = new TFile('test-project.md'); + mockPlugin.app.metadataCache.getFirstLinkpathDest = jest.fn().mockReturnValue(mockFile); + + const card = createTaskCard(task, mockPlugin); + const projectLink = card.querySelector('.task-card__project-link') as HTMLElement; + + expect(projectLink).toBeTruthy(); + + // Simulate click + const clickEvent = new MouseEvent('click', { bubbles: true }); + projectLink.dispatchEvent(clickEvent); + + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockPlugin.app.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith('Test Project', ''); + expect(mockPlugin.app.workspace.getLeaf).toHaveBeenCalledWith(false); + }); + + it('should show notice when project link file not found', async () => { + const task = TaskFactory.createTask({ + projects: ['[[Nonexistent Project]]'] + }); + + mockPlugin.app.metadataCache.getFirstLinkpathDest = jest.fn().mockReturnValue(null); + + const card = createTaskCard(task, mockPlugin); + const projectLink = card.querySelector('.task-card__project-link') as HTMLElement; + + expect(projectLink).toBeTruthy(); + + // Simulate click + const clickEvent = new MouseEvent('click', { bubbles: true }); + projectLink.dispatchEvent(clickEvent); + + // Wait for async operations + await new Promise(resolve => setTimeout(resolve, 0)); + + expect(mockPlugin.app.metadataCache.getFirstLinkpathDest).toHaveBeenCalledWith('Nonexistent Project', ''); + expect(Notice).toHaveBeenCalledWith('Note "Nonexistent Project" not found'); + }); + it('should hide metadata line when no metadata available', () => { const task = TaskFactory.createTask({ due: undefined,