mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
b46f9b3ea5
commit
cefb1f2113
3 changed files with 259 additions and 15 deletions
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue