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
This commit is contained in:
Callum Alpass 2025-07-05 11:09:40 +10:00
parent b46f9b3ea5
commit cefb1f2113
3 changed files with 259 additions and 15 deletions

View file

@ -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);
}
});
}

View file

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

View file

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