mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
commit
fe288fb3b5
3 changed files with 129 additions and 4 deletions
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue