From 744d3b2b280579307f7e6d1248aaea20c1b6dcad Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 1 May 2026 21:55:23 +0200 Subject: [PATCH 1/2] fix(shortcuts): keep shortcuts working while a tooltip is open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A lingering Material tooltip pane (mat-mdc-tooltip-panel) leaves a CDK overlay mounted, which made _hasOpenCdkOverlay() report a blocking overlay and silently swallow every keyboard shortcut until the tooltip faded out. Tooltips are non-interactive — exclude them from the check. --- src/app/core-ui/shortcut/shortcut.service.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app/core-ui/shortcut/shortcut.service.ts b/src/app/core-ui/shortcut/shortcut.service.ts index 4836036f2f..f7faf09014 100644 --- a/src/app/core-ui/shortcut/shortcut.service.ts +++ b/src/app/core-ui/shortcut/shortcut.service.ts @@ -26,6 +26,7 @@ import { TODAY_TAG } from '../../features/tag/tag.const'; // If CDK changes these class names we only need to adjust the helpers below. const CDK_OVERLAY_CONTAINER_CLASS = 'cdk-overlay-container'; const CDK_OVERLAY_PANE_CLASS = 'cdk-overlay-pane'; +const MAT_TOOLTIP_PANEL_CLASS = 'mat-mdc-tooltip-panel'; @Injectable({ providedIn: 'root', @@ -244,7 +245,9 @@ export class ShortcutService { // NOTE: All CDK class name knowledge is encapsulated here to ease future updates. return Array.from(containerEl.children).some((child) => { return ( - child.classList.contains(CDK_OVERLAY_PANE_CLASS) && child.childElementCount > 0 + child.classList.contains(CDK_OVERLAY_PANE_CLASS) && + child.childElementCount > 0 && + !child.classList.contains(MAT_TOOLTIP_PANEL_CLASS) ); }); } From 2105bbc86085d15a51c6ac9f96a533c688521ac3 Mon Sep 17 00:00:00 2001 From: Johannes Millan Date: Fri, 1 May 2026 21:55:30 +0200 Subject: [PATCH 2/2] fix(tasks): track focus on inner elements without subtask bubbling The 'focus' event does not bubble, so clicking a button inside a task (title editor, hover controls, etc.) left focusedTaskId stale and subsequent shortcuts targeted the wrong task. Switching to 'focusin' fixes that, but its bubbling means a nested subtask's focusin would also reach every ancestor task host and overwrite focusedTaskId with the parent's id. Add an innermost-task guard so only the closest ancestor of the event target claims focus. Pair 'focusout' with a relatedTarget check to avoid clearing focusedTaskId when focus moves between elements within the same task. --- .../task/task-on-focus-panel-sync.spec.ts | 103 +++++++++++++++++- src/app/features/tasks/task/task.component.ts | 25 ++++- 2 files changed, 125 insertions(+), 3 deletions(-) diff --git a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts index e464acee47..effe5fd7a3 100644 --- a/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts +++ b/src/app/features/tasks/task/task-on-focus-panel-sync.spec.ts @@ -31,13 +31,24 @@ describe('Task onFocus detail panel sync (#6578)', () => { /** * Simulates the onFocus handler logic from task.component.ts. - * This mirrors the exact logic in the component's @HostListener('focus') handler. + * This mirrors the exact logic in the component's @HostListener('focusin') handler, + * including the innermost-task guard that prevents bubbled events from a nested + * from being claimed by an ancestor task host. */ const simulateOnFocus = ( taskId: string, isInsideDetailPanel: boolean, componentRef: unknown, + eventTarget: EventTarget | null = null, + hostEl: HTMLElement | null = null, ): void => { + if ( + hostEl && + eventTarget instanceof Element && + eventTarget.closest('task') !== hostEl + ) { + return; + } mockTaskFocusService.focusedTaskId.set(taskId); mockTaskFocusService.lastFocusedTaskComponent.set(componentRef); @@ -50,6 +61,20 @@ describe('Task onFocus detail panel sync (#6578)', () => { } }; + const simulateOnBlur = ( + hostEl: HTMLElement, + relatedTarget: EventTarget | null, + eventTarget: EventTarget | null = hostEl, + ): void => { + if (eventTarget instanceof Element && eventTarget.closest('task') !== hostEl) { + return; + } + if (relatedTarget instanceof Node && hostEl.contains(relatedTarget)) { + return; + } + mockTaskFocusService.focusedTaskId.set(null); + }; + it('should update selectedTaskId when panel is open for a different task', () => { mockTaskService.selectedTaskId.set(TASK_A_ID); @@ -93,4 +118,80 @@ describe('Task onFocus detail panel sync (#6578)', () => { expect(mockTaskFocusService.focusedTaskId()).toBe('subtask-1'); }); + + it('should keep focusedTaskId when focus moves within the same task', () => { + const taskEl = document.createElement('task'); + const childEl = document.createElement('button'); + taskEl.appendChild(childEl); + mockTaskFocusService.focusedTaskId.set(TASK_B_ID); + + simulateOnBlur(taskEl, childEl); + + expect(mockTaskFocusService.focusedTaskId()).toBe(TASK_B_ID); + }); + + it('should clear focusedTaskId when focus leaves the task', () => { + const taskEl = document.createElement('task'); + const outsideEl = document.createElement('button'); + mockTaskFocusService.focusedTaskId.set(TASK_B_ID); + + simulateOnBlur(taskEl, outsideEl); + + expect(mockTaskFocusService.focusedTaskId()).toBeNull(); + }); + + describe('focusin bubbling from nested tasks', () => { + // Nested DOM: