Merge branch 'feat/ensureoverlaysclosed-1-backdrop-s-0-1f8487'

* feat/ensureoverlaysclosed-1-backdrop-s-0-1f8487:
  fix(tasks): track focus on inner elements without subtask bubbling
  fix(shortcuts): keep shortcuts working while a tooltip is open
This commit is contained in:
Johannes Millan 2026-05-01 21:58:50 +02:00
commit fe288fb3b5
3 changed files with 129 additions and 4 deletions

View file

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

View file

@ -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
* <task> 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: <task id=parent> <task id=sub> <button> </task> </task>
// focusin from <button> bubbles to <task id=sub> (innermost) AND <task id=parent>.
// Only the innermost task should claim focus.
const buildNested = (): {
parentEl: HTMLElement;
subEl: HTMLElement;
innerBtn: HTMLElement;
} => {
const parentEl = document.createElement('task');
const subEl = document.createElement('task');
const innerBtn = document.createElement('button');
subEl.appendChild(innerBtn);
parentEl.appendChild(subEl);
return { parentEl, subEl, innerBtn };
};
it('should not let parent task overwrite focusedTaskId when subtask is focused', () => {
const { parentEl, subEl, innerBtn } = buildNested();
// DOM event order: subtask handler fires first (innermost), then parent.
simulateOnFocus('sub-id', false, {}, innerBtn, subEl);
simulateOnFocus('parent-id', false, {}, innerBtn, parentEl);
expect(mockTaskFocusService.focusedTaskId()).toBe('sub-id');
});
it('should not let parent task setSelectedId when subtask child is focused', () => {
const { parentEl, subEl, innerBtn } = buildNested();
mockTaskService.selectedTaskId.set('some-other-id');
simulateOnFocus('sub-id', false, {}, innerBtn, subEl);
simulateOnFocus('parent-id', false, {}, innerBtn, parentEl);
expect(mockTaskService.setSelectedId).toHaveBeenCalledWith('sub-id');
expect(mockTaskService.setSelectedId).not.toHaveBeenCalledWith('parent-id');
});
it('should not let parent clear focusedTaskId on bubbled focusout from subtask', () => {
const { parentEl, subEl, innerBtn } = buildNested();
mockTaskFocusService.focusedTaskId.set('sub-id');
const outsideEl = document.createElement('div');
// focusout from innerBtn bubbles to sub (clears) and to parent.
// Parent must not run its body — the event didn't originate from the parent's row.
simulateOnBlur(subEl, outsideEl, innerBtn);
// After sub clears, parent would re-clear (no-op here, but verify the guard
// also prevents parent from running when state is non-null):
mockTaskFocusService.focusedTaskId.set('parent-id');
simulateOnBlur(parentEl, outsideEl, innerBtn);
expect(mockTaskFocusService.focusedTaskId()).toBe('parent-id');
});
});
});

View file

@ -328,7 +328,12 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
// methods come last
@HostListener('focus') onFocus(): void {
@HostListener('focusin', ['$event']) onFocus(ev: FocusEvent): void {
// focusin bubbles, so events from nested <task> elements (subtasks) reach
// every ancestor task host. Only the innermost task should claim focus.
if (!this._isInnermostTaskFor(ev.target)) {
return;
}
this._taskFocusService.focusedTaskId.set(this.task().id);
this._taskFocusService.lastFocusedTaskComponent.set(this);
@ -343,10 +348,26 @@ export class TaskComponent implements OnDestroy, AfterViewInit {
}
}
@HostListener('blur') onBlur(): void {
@HostListener('focusout', ['$event']) onBlur(ev: FocusEvent): void {
if (!this._isInnermostTaskFor(ev.target)) {
return;
}
if (
ev.relatedTarget instanceof Node &&
this._elementRef.nativeElement.contains(ev.relatedTarget)
) {
return;
}
this._taskFocusService.focusedTaskId.set(null);
}
private _isInnermostTaskFor(target: EventTarget | null): boolean {
return (
target instanceof Element &&
target.closest('task') === this._elementRef.nativeElement
);
}
@HostListener('dragenter', ['$event']) onDragEnter(ev: DragEvent): void {
this._dragEnterTarget = ev.target as HTMLElement;
ev.preventDefault();