Skip to content

Commit 9ccf78b

Browse files
authored
Fix Escape key to close active elements before collapsing filter drawer
Previously, pressing Escape would minimise the entire window or collapse the filter drawer unintentionally. This change intercepts the Escape key in FilterBarComponent to: - Close any open mat-select dropdowns - Blur the search input if focused - Close the label filter menu if open If no interactive element is active, the event bubbles up to trigger the drawer close as expected. Improves keyboard accessibility and aligns with user expectations for modal/interactive behaviour.
1 parent 8d5b574 commit 9ccf78b

File tree

4 files changed

+198
-128
lines changed

4 files changed

+198
-128
lines changed

src/app/issues-viewer/issues-viewer.component.html

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
<mat-drawer-container class="drawer-container">
99
<mat-drawer #sidenav mode="side" opened>
1010
<div class="left">
11-
<app-filter-bar [views$]="views"></app-filter-bar>
11+
<app-filter-bar
12+
[views$]="views"
13+
(escapePressed)="sidenav.close()"
14+
>
15+
</app-filter-bar>
1216
</div>
1317
</mat-drawer>
1418
<mat-drawer-content>
Lines changed: 129 additions & 126 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,132 @@
1-
<ul>
2-
<li>
3-
<mat-form-field class="search-bar">
4-
<input
5-
matInput
6-
value="{{ this.filtersService.filter$.value.title }}"
7-
(keyup)="this.filtersService.updateFilters({ title: $event.target.value })"
8-
placeholder="Search"
9-
/>
10-
</mat-form-field>
11-
</li>
1+
<div tabindex="0" (keydown.escape)="handleEscape($event)">
2+
<ul>
3+
<li>
4+
<mat-form-field class="search-bar">
5+
<input
6+
#searchInputRef
7+
matInput
8+
value="{{ this.filtersService.filter$.value.title }}"
9+
(keyup)="this.filtersService.updateFilters({ title: $event.target.value })"
10+
placeholder="Search"
11+
/>
12+
</mat-form-field>
13+
</li>
1214

13-
<li class="filters">
14-
<mat-form-field appearance="standard" class="filter-item">
15-
<mat-label>Group by</mat-label>
16-
<mat-select
17-
[value]="this.groupingContextService.currGroupBy$ | async"
18-
panelClass="filter-bar-panel"
19-
(selectionChange)="this.groupingContextService.setCurrentGroupingType($event.value)"
20-
>
21-
<mat-option *ngFor="let option of this.groupByEnum | keyvalue" [value]="option.value">{{ option.key }}</mat-option>
22-
</mat-select>
23-
</mat-form-field>
24-
<mat-form-field appearance="standard" class="filter-item">
25-
<mat-label>Status</mat-label>
26-
<mat-select
27-
[value]="this.filter.status"
28-
panelClass="filter-bar-panel"
29-
(selectionChange)="this.filtersService.updateFilters({ status: $event.value })"
30-
multiple
31-
>
32-
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.OpenPullRequests">Open Pull Requests</mat-option>
33-
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.MergedPullRequests">Merged Pull Requests</mat-option>
34-
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.ClosedPullRequests">Closed Pull Request</mat-option>
35-
<mat-option *ngIf="isFilterIssue()" [value]="statusOptions.OpenIssues">Open Issues</mat-option>
36-
<mat-option *ngIf="isFilterIssue()" [value]="statusOptions.ClosedIssues">Closed Issues</mat-option>
37-
</mat-select>
38-
</mat-form-field>
39-
<mat-form-field appearance="standard" class="filter-item">
40-
<mat-label>Type</mat-label>
41-
<mat-select
42-
[value]="this.filter.type"
43-
panelClass="filter-bar-panel"
44-
(selectionChange)="this.filtersService.updateFilters({ type: $event.value })"
45-
>
46-
<mat-option [value]="typeOptions.All">All</mat-option>
47-
<mat-option [value]="typeOptions.Issue">Issue</mat-option>
48-
<mat-option [value]="typeOptions.PullRequests">Pull Request</mat-option>
49-
</mat-select>
50-
</mat-form-field>
51-
<mat-form-field
52-
appearance="standard"
53-
matSort
54-
[matSortDisableClear]="true"
55-
(matSortChange)="this.filtersService.updateFilters({ sort: $event })"
56-
class="filter-item"
57-
>
58-
<mat-label>Sort</mat-label>
59-
<mat-select [value]="this.filter.sort.active" panelClass="filter-bar-panel">
60-
<mat-option [value]="sortOptions.Id">
61-
<span mat-sort-header="id">ID</span>
62-
</mat-option>
63-
<mat-option [value]="sortOptions.Title">
64-
<span mat-sort-header="title">Title</span>
65-
</mat-option>
66-
<mat-option [value]="sortOptions.Date">
67-
<span mat-sort-header="date">Date Updated</span>
68-
</mat-option>
69-
<mat-option [value]="sortOptions.Status">
70-
<span mat-sort-header="status">Status</span>
71-
</mat-option>
72-
</mat-select>
73-
</mat-form-field>
74-
<mat-form-field appearance="standard" class="filter-item">
75-
<mat-label>Milestone</mat-label>
76-
<mat-select
77-
#milestoneSelectorRef
78-
[value]="this.filter.milestones"
79-
panelClass="filter-bar-panel"
80-
(selectionChange)="this.filtersService.updateFilters({ milestones: $event.value })"
81-
[disabled]="this.milestoneService.hasNoMilestones"
82-
multiple
83-
>
84-
<mat-select-trigger *ngIf="this.milestoneService.hasNoMilestones">
85-
<span>No Milestones</span>
86-
</mat-select-trigger>
87-
<mat-option *ngFor="let milestone of this.milestoneService.milestones" [value]="milestone.title">
88-
{{ milestone.title }}
89-
</mat-option>
90-
<mat-option *ngIf="isFilterIssue()" [value]="milestoneOptions.IssueWithoutMilestone">Issues without a milestone</mat-option>
91-
<mat-option *ngIf="isFilterPullRequest()" [value]="milestoneOptions.PullRequestWithoutMilestone"
92-
>PRs without a milestone</mat-option
15+
<li class="filters">
16+
<mat-form-field appearance="standard" class="filter-item">
17+
<mat-label>Group by</mat-label>
18+
<mat-select
19+
[value]="this.groupingContextService.currGroupBy$ | async"
20+
panelClass="filter-bar-panel"
21+
(selectionChange)="this.groupingContextService.setCurrentGroupingType($event.value)"
9322
>
94-
</mat-select>
95-
</mat-form-field>
96-
<mat-form-field appearance="standard" class="filter-item">
97-
<mat-label>Items per page</mat-label>
98-
<mat-select
99-
[value]="this.filter.itemsPerPage"
100-
panelClass="filter-bar-panel"
101-
(selectionChange)="this.filtersService.updateFilters({ itemsPerPage: $event.value })"
102-
>
103-
<mat-option [value]="10">10</mat-option>
104-
<mat-option [value]="20">20</mat-option>
105-
<mat-option [value]="50">50</mat-option>
106-
</mat-select>
107-
</mat-form-field>
108-
<mat-form-field appearance="standard">
109-
<mat-label>Assigned to</mat-label>
110-
<mat-select
111-
#assigneeSelectorRef
112-
[value]="this.filter.assignees"
113-
panelClass="filter-bar-panel"
114-
(selectionChange)="this.filtersService.updateFilters({ assignees: $event.value })"
115-
[disabled]="this.assigneeService.hasNoAssignees"
116-
multiple
23+
<mat-option *ngFor="let option of this.groupByEnum | keyvalue" [value]="option.value">{{ option.key }}</mat-option>
24+
</mat-select>
25+
</mat-form-field>
26+
<mat-form-field appearance="standard" class="filter-item">
27+
<mat-label>Status</mat-label>
28+
<mat-select
29+
[value]="this.filter.status"
30+
panelClass="filter-bar-panel"
31+
(selectionChange)="this.filtersService.updateFilters({ status: $event.value })"
32+
multiple
33+
>
34+
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.OpenPullRequests">Open Pull Requests</mat-option>
35+
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.MergedPullRequests">Merged Pull Requests</mat-option>
36+
<mat-option *ngIf="isFilterPullRequest()" [value]="statusOptions.ClosedPullRequests">Closed Pull Request</mat-option>
37+
<mat-option *ngIf="isFilterIssue()" [value]="statusOptions.OpenIssues">Open Issues</mat-option>
38+
<mat-option *ngIf="isFilterIssue()" [value]="statusOptions.ClosedIssues">Closed Issues</mat-option>
39+
</mat-select>
40+
</mat-form-field>
41+
<mat-form-field appearance="standard" class="filter-item">
42+
<mat-label>Type</mat-label>
43+
<mat-select
44+
[value]="this.filter.type"
45+
panelClass="filter-bar-panel"
46+
(selectionChange)="this.filtersService.updateFilters({ type: $event.value })"
47+
>
48+
<mat-option [value]="typeOptions.All">All</mat-option>
49+
<mat-option [value]="typeOptions.Issue">Issue</mat-option>
50+
<mat-option [value]="typeOptions.PullRequests">Pull Request</mat-option>
51+
</mat-select>
52+
</mat-form-field>
53+
<mat-form-field
54+
appearance="standard"
55+
matSort
56+
[matSortDisableClear]="true"
57+
(matSortChange)="this.filtersService.updateFilters({ sort: $event })"
58+
class="filter-item"
11759
>
118-
<mat-select-trigger *ngIf="this.assigneeService.hasNoAssignees">
119-
<span>No Assignees</span>
120-
</mat-select-trigger>
121-
<mat-option *ngFor="let assignee of this.assigneeService.assignees" [value]="assignee.login">
122-
{{ assignee.login }}
123-
</mat-option>
124-
<mat-option [value]="'Unassigned'">Unassigned</mat-option>
125-
</mat-select>
126-
</mat-form-field>
127-
<app-label-filter-bar></app-label-filter-bar>
128-
</li>
129-
</ul>
60+
<mat-label>Sort</mat-label>
61+
<mat-select [value]="this.filter.sort.active" panelClass="filter-bar-panel">
62+
<mat-option [value]="sortOptions.Id">
63+
<span mat-sort-header="id">ID</span>
64+
</mat-option>
65+
<mat-option [value]="sortOptions.Title">
66+
<span mat-sort-header="title">Title</span>
67+
</mat-option>
68+
<mat-option [value]="sortOptions.Date">
69+
<span mat-sort-header="date">Date Updated</span>
70+
</mat-option>
71+
<mat-option [value]="sortOptions.Status">
72+
<span mat-sort-header="status">Status</span>
73+
</mat-option>
74+
</mat-select>
75+
</mat-form-field>
76+
<mat-form-field appearance="standard" class="filter-item">
77+
<mat-label>Milestone</mat-label>
78+
<mat-select
79+
#milestoneSelectorRef
80+
[value]="this.filter.milestones"
81+
panelClass="filter-bar-panel"
82+
(selectionChange)="this.filtersService.updateFilters({ milestones: $event.value })"
83+
[disabled]="this.milestoneService.hasNoMilestones"
84+
multiple
85+
>
86+
<mat-select-trigger *ngIf="this.milestoneService.hasNoMilestones">
87+
<span>No Milestones</span>
88+
</mat-select-trigger>
89+
<mat-option *ngFor="let milestone of this.milestoneService.milestones" [value]="milestone.title">
90+
{{ milestone.title }}
91+
</mat-option>
92+
<mat-option *ngIf="isFilterIssue()" [value]="milestoneOptions.IssueWithoutMilestone">Issues without a milestone</mat-option>
93+
<mat-option *ngIf="isFilterPullRequest()" [value]="milestoneOptions.PullRequestWithoutMilestone"
94+
>PRs without a milestone</mat-option
95+
>
96+
</mat-select>
97+
</mat-form-field>
98+
<mat-form-field appearance="standard" class="filter-item">
99+
<mat-label>Items per page</mat-label>
100+
<mat-select
101+
[value]="this.filter.itemsPerPage"
102+
panelClass="filter-bar-panel"
103+
(selectionChange)="this.filtersService.updateFilters({ itemsPerPage: $event.value })"
104+
>
105+
<mat-option [value]="10">10</mat-option>
106+
<mat-option [value]="20">20</mat-option>
107+
<mat-option [value]="50">50</mat-option>
108+
</mat-select>
109+
</mat-form-field>
110+
<mat-form-field appearance="standard">
111+
<mat-label>Assigned to</mat-label>
112+
<mat-select
113+
#assigneeSelectorRef
114+
[value]="this.filter.assignees"
115+
panelClass="filter-bar-panel"
116+
(selectionChange)="this.filtersService.updateFilters({ assignees: $event.value })"
117+
[disabled]="this.assigneeService.hasNoAssignees"
118+
multiple
119+
>
120+
<mat-select-trigger *ngIf="this.assigneeService.hasNoAssignees">
121+
<span>No Assignees</span>
122+
</mat-select-trigger>
123+
<mat-option *ngFor="let assignee of this.assigneeService.assignees" [value]="assignee.login">
124+
{{ assignee.login }}
125+
</mat-option>
126+
<mat-option [value]="'Unassigned'">Unassigned</mat-option>
127+
</mat-select>
128+
</mat-form-field>
129+
<app-label-filter-bar></app-label-filter-bar>
130+
</li>
131+
</ul>
132+
</div>

src/app/shared/filter-bar/filter-bar.component.ts

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,17 @@
1-
import { AfterViewInit, Component, Input, OnDestroy, OnInit, QueryList, Type, ViewChild } from '@angular/core';
1+
import {
2+
AfterViewInit,
3+
Component,
4+
EventEmitter,
5+
HostListener,
6+
Input,
7+
OnDestroy,
8+
OnInit,
9+
Output,
10+
QueryList,
11+
Type,
12+
ViewChild,
13+
ViewChildren
14+
} from '@angular/core';
215
import { MatSelect } from '@angular/material/select';
316
import { BehaviorSubject, Subscription } from 'rxjs';
417
import { MilestoneOptions, SortOptions, StatusOptions, TypeOptions } from '../../core/constants/filter-options.constants';
@@ -42,6 +55,12 @@ export class FilterBarComponent implements OnInit, OnDestroy {
4255

4356
@ViewChild('milestoneSelectorRef', { static: false }) milestoneSelectorRef: MatSelect;
4457

58+
@ViewChildren(MatSelect) matSelects!: QueryList<MatSelect>;
59+
60+
@ViewChild('searchInputRef') searchInputRef: any;
61+
62+
@Output() escapePressed = new EventEmitter<void>();
63+
4564
constructor(
4665
public assigneeService: AssigneeService,
4766
public milestoneService: MilestoneService,
@@ -112,4 +131,36 @@ export class FilterBarComponent implements OnInit, OnDestroy {
112131
() => {}
113132
);
114133
}
134+
135+
/**
136+
* Handles Escape key interactions within the filter bar:
137+
*
138+
* Priority of actions:
139+
* 1. Closes the first open dropdown (`mat-select`) if any are open.
140+
* 2. Blurs the search input if it's currently focused.
141+
* 3. Closes the label filter bar if it's open.
142+
* 4. Emits `escapePressed` to notify the parent to close the filter drawer.
143+
*
144+
* Always prevents default and stops propagation to avoid:
145+
* - Window minimizing (especially in Electron/desktop wrappers)
146+
* - Accidental closure of unrelated components
147+
*/
148+
@HostListener('document:keydown.escape', ['$event'])
149+
handleEscape(event: KeyboardEvent) {
150+
const openDropdown = this.matSelects.find((select) => select.panelOpen);
151+
152+
if (openDropdown) {
153+
openDropdown.close();
154+
} else if (this.searchInputRef && document.activeElement === this.searchInputRef.nativeElement) {
155+
this.searchInputRef.nativeElement.blur();
156+
} else if (this.labelFilterBar?.isOpen()) {
157+
this.labelFilterBar.closeMenu();
158+
} else {
159+
this.escapePressed.emit();
160+
}
161+
162+
event.preventDefault();
163+
event.stopImmediatePropagation();
164+
}
115165
}
166+

src/app/shared/filter-bar/label-filter-bar/label-filter-bar.component.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { AfterViewInit, Component, OnDestroy, OnInit, ViewChild } from '@angular/core';
2+
import { MatMenuTrigger } from '@angular/material/menu';
23
import { Observable, Subscription } from 'rxjs';
34
import { SimpleLabel } from '../../../core/models/label.model';
45
import { FiltersService } from '../../../core/services/filters.service';
@@ -26,6 +27,8 @@ export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy
2627

2728
labelSubscription: Subscription;
2829

30+
@ViewChild(MatMenuTrigger) menuTrigger: MatMenuTrigger;
31+
2932
constructor(private labelService: LabelService, private logger: LoggingService, private filtersService: FiltersService) {}
3033

3134
ngOnInit() {
@@ -137,4 +140,13 @@ export class LabelFilterBarComponent implements OnInit, AfterViewInit, OnDestroy
137140
this.isDefault = true;
138141
this.updateSelection();
139142
}
143+
144+
145+
isOpen(): boolean {
146+
return this.menuTrigger?.menuOpen || false;
147+
}
148+
149+
closeMenu(): void {
150+
this.menuTrigger.closeMenu();
151+
}
140152
}

0 commit comments

Comments
 (0)