Skip to content

Commit 99f305c

Browse files
committed
fix(filter-bar): restore focus after closing (#DS-4187)
1 parent c5c1a45 commit 99f305c

File tree

7 files changed

+133
-16
lines changed

7 files changed

+133
-16
lines changed

packages/components/filter-bar/filter-bar-button.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,21 @@ import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
33
import { KbqButton, KbqButtonStyles } from '@koobiq/components/button';
44
import { KbqComponentColors } from '@koobiq/components/core';
55
import { KbqFilterBar } from './filter-bar';
6+
import { KbqFilters } from './filters';
67

78
@Directive({
8-
selector: '[kbqFilterBarButton]'
9+
selector: '[kbqFilterBarButton]',
10+
host: {
11+
'(click)': 'saveFocusedElement()',
12+
'(keydown)': 'saveFocusedElement()'
13+
}
914
})
1015
export class KbqFilterBarButton {
1116
private readonly button = inject(KbqButton);
1217
/** KbqFilterBar instance */
1318
private readonly filterBar = inject(KbqFilterBar);
19+
/** KbqFilters instance */
20+
protected readonly filters = inject(KbqFilters);
1421

1522
constructor() {
1623
this.filterBar.changes.pipe(takeUntilDestroyed()).subscribe(() => {
@@ -23,4 +30,8 @@ export class KbqFilterBarButton {
2330
}
2431
});
2532
}
33+
34+
saveFocusedElement() {
35+
this.filters.saveFocusedElement(this.button);
36+
}
2637
}

packages/components/filter-bar/filters.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
<button
2+
#mainButton
23
kbq-button
34
kbq-title
45
kbqFilterBarButton
@@ -13,8 +14,8 @@
1314
[kbqPopoverPlacementPriority]="[placements.BottomLeft, placements.TopLeft]"
1415
[kbqPopoverSize]="popoverSize"
1516
[kbqTrigger]="'manual'"
16-
[class.kbq-active]="opened"
1717
[disabled]="isSaving"
18+
[class.kbq-active]="focusedElementBeforeIs(mainButton) && opened"
1819
(dropdownClosed)="searchControl.setValue('')"
1920
(dropdownOpened)="onDropdownOpen()"
2021
>
@@ -32,10 +33,12 @@
3233

3334
@if (filterBar.isChanged && !filterBar.isSaved) {
3435
<button
36+
#saveNewFilterButton
3537
kbqTooltip="{{ localeData.saveNewFilterTooltip }}"
3638
kbq-button
3739
kbqFilterBarButton
3840
class="kbq-button_action"
41+
[class.kbq-active]="focusedElementBeforeIs(saveNewFilterButton) && opened"
3942
[color]="colors.Empty"
4043
[disabled]="isSaving"
4144
(click)="openSaveAsNewFilterPopover()"
@@ -46,12 +49,14 @@
4649

4750
@if (filterBar.isSaved) {
4851
<button
52+
#filterActionsButton
4953
kbq-button
5054
kbqFilterBarButton
5155
class="kbq-button_action"
5256
[color]="colors.Empty"
5357
[kbqDropdownTriggerFor]="filterActions"
5458
[disabled]="isSaving"
59+
[class.kbq-active]="focusedElementBeforeIs(filterActionsButton) && filterActionsDropdownOpened"
5560
[ngClass]="{ 'kbq-button_changed-saved-filter': filterBar.isSavedAndChanged }"
5661
>
5762
<i kbq-icon="kbq-ellipsis-vertical_16"></i>
@@ -142,7 +147,7 @@
142147
</button>
143148
}
144149
@if (!filterBar.isReadOnly) {
145-
<button kbq-dropdown-item (click)="this.onRemoveFilter.next(this.filter!)">
150+
<button kbq-dropdown-item (click)="removeFilter()">
146151
<i kbq-icon="kbq-trash_16"></i>
147152
{{ localeData.remove }}
148153
</button>
@@ -191,7 +196,7 @@
191196
>
192197
{{ localeData.saveButton }}
193198
</button>
194-
<button kbq-button [color]="'contrast-fade'" [kbqStyle]="'filled'" [disabled]="isSaving" (click)="closePopover()">
199+
<button kbq-button [color]="'contrast-fade'" [kbqStyle]="'filled'" [disabled]="isSaving" (click)="popover.hide()">
195200
{{ localeData.cancelButton }}
196201
</button>
197202
</ng-template>

packages/components/filter-bar/filters.ts

Lines changed: 73 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
1+
import { FocusMonitor, FocusOrigin } from '@angular/cdk/a11y';
12
import { AsyncPipe, NgClass } from '@angular/common';
23
import {
34
ChangeDetectionStrategy,
45
ChangeDetectorRef,
56
Component,
7+
DestroyRef,
68
ElementRef,
79
EventEmitter,
810
inject,
@@ -12,6 +14,7 @@ import {
1214
ViewChild,
1315
ViewEncapsulation
1416
} from '@angular/core';
17+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
1518
import { FormControl, FormsModule, ReactiveFormsModule, UntypedFormControl, Validators } from '@angular/forms';
1619
import { KbqAlertModule } from '@koobiq/components/alert';
1720
import { KbqButton, KbqButtonModule, KbqButtonStyles } from '@koobiq/components/button';
@@ -60,6 +63,12 @@ import { KbqFilter, KbqSaveFilterError, KbqSaveFilterEvent, KbqSaveFilterStatuse
6063
}
6164
})
6265
export class KbqFilters implements OnInit {
66+
/** @docs-private */
67+
protected readonly elementRef = inject(ElementRef);
68+
/** @docs-private */
69+
protected readonly destroyRef = inject(DestroyRef);
70+
/** @docs-private */
71+
protected readonly focusMonitor = inject(FocusMonitor);
6372
/** @docs-private */
6473
protected readonly placements = PopUpPlacements;
6574
/** @docs-private */
@@ -73,9 +82,14 @@ export class KbqFilters implements OnInit {
7382
/** @docs-private */
7483
private readonly changeDetectorRef = inject(ChangeDetectorRef);
7584

76-
@ViewChild(KbqButton) private button: KbqButton;
77-
@ViewChild(KbqPopoverTrigger) private popover: KbqPopoverTrigger;
78-
@ViewChild(KbqDropdownTrigger) private dropdown: KbqDropdownTrigger;
85+
@ViewChild('mainButton') protected mainButton: KbqButton;
86+
@ViewChild('saveNewFilterButton') protected saveNewFilterButton: KbqButton;
87+
@ViewChild('filterActionsButton') protected filterActionsButton: KbqButton;
88+
89+
@ViewChild(KbqPopoverTrigger) protected popover: KbqPopoverTrigger;
90+
@ViewChild(KbqDropdownTrigger) protected dropdown: KbqDropdownTrigger;
91+
@ViewChild('filterActionsButton') protected filterActionsDropdown: KbqDropdownTrigger;
92+
7993
@ViewChild('search') private search: ElementRef;
8094
@ViewChild('newFilterName') private newFilterName: ElementRef;
8195
@ViewChild('saveFilterButton') private saveFilterButton: KbqButton;
@@ -125,6 +139,11 @@ export class KbqFilters implements OnInit {
125139
return this.popover?.isOpen || this.dropdown?.opened;
126140
}
127141

142+
/** Component state. true if opened dropdown or popup */
143+
get filterActionsDropdownOpened(): boolean {
144+
return this.popover?.isOpen || this.filterActionsDropdown?.opened;
145+
}
146+
128147
/** Selected filter */
129148
get filter(): KbqFilter | null {
130149
return this.filterBar.filter;
@@ -141,6 +160,16 @@ export class KbqFilters implements OnInit {
141160
return this.filterBar.configuration.filters;
142161
}
143162

163+
/** Current focus origin state.
164+
* @docs-private */
165+
get focusOrigin(): FocusOrigin {
166+
return this._focusOrigin;
167+
}
168+
169+
private _focusOrigin: FocusOrigin = null;
170+
171+
private focusedElementBeforeOpen: KbqButton | null;
172+
144173
constructor() {
145174
this.filterBar.changes.subscribe(() => this.changeDetectorRef.markForCheck());
146175
}
@@ -150,6 +179,18 @@ export class KbqFilters implements OnInit {
150179
of(this.filters),
151180
this.searchControl.valueChanges.pipe(map((value) => this.getFilteredOptions(value)))
152181
);
182+
183+
this.focusMonitor
184+
.monitor(this.elementRef, true)
185+
.pipe(
186+
filter((origin) => !!origin),
187+
takeUntilDestroyed(this.destroyRef)
188+
)
189+
.subscribe((origin) => (this._focusOrigin = origin));
190+
}
191+
192+
focusedElementBeforeIs(button: KbqButton): boolean {
193+
return this.focusedElementBeforeOpen === button;
153194
}
154195

155196
selectFilter(filter: KbqFilter) {
@@ -210,7 +251,10 @@ export class KbqFilters implements OnInit {
210251
}
211252

212253
restoreFocus() {
213-
this.button.focus();
254+
if (this.focusedElementBeforeOpen && !this.focusedElementBeforeOpen.disabled) {
255+
this.focusMonitor.focusVia(this.focusedElementBeforeOpen.elementRef, this.focusOrigin);
256+
this.focusedElementBeforeOpen = null;
257+
}
214258
}
215259

216260
preparePopover() {
@@ -221,11 +265,19 @@ export class KbqFilters implements OnInit {
221265
this.popover.show();
222266

223267
merge(...this.popover.defaultClosingActions())
224-
.pipe(filter(() => !this.isSaving))
268+
.pipe(
269+
filter(() => !this.isSaving),
270+
takeUntilDestroyed(this.popover.instanceDestroyRef)
271+
)
272+
.subscribe(() => this.closePopover(false));
273+
274+
this.popover.visibleChange
275+
.pipe(
276+
filter((state) => !state),
277+
takeUntilDestroyed(this.popover.instanceDestroyRef)
278+
)
225279
.subscribe(this.closePopover);
226280

227-
this.popover.visibleChange.pipe(filter((state) => !state)).subscribe(this.closePopover);
228-
229281
setTimeout(() => {
230282
this.newFilterName.nativeElement.focus();
231283
this.filterName.setErrors(null);
@@ -244,10 +296,16 @@ export class KbqFilters implements OnInit {
244296
this.preparePopover();
245297
}
246298

247-
closePopover = () => {
299+
saveFocusedElement(button?: KbqButton) {
300+
this.focusedElementBeforeOpen = button || null;
301+
}
302+
303+
closePopover = (restoreFocus: boolean = true) => {
248304
this.popover.hide();
249305

250-
this.restoreFocus();
306+
if (restoreFocus) {
307+
this.restoreFocus();
308+
}
251309

252310
setTimeout(() => this.changeDetectorRef.detectChanges());
253311

@@ -280,6 +338,12 @@ export class KbqFilters implements OnInit {
280338
this.onResetFilterChanges.emit(this.filter!);
281339
}
282340

341+
removeFilter() {
342+
this.onRemoveFilter.next(this.filter!);
343+
344+
setTimeout(() => this.focusMonitor.focusVia(this.mainButton.elementRef, this.focusOrigin), 0);
345+
}
346+
283347
/** Hide the popup and restore focus.
284348
* Use this method in the onSave, onSaveAsNew, or onChangeFilter events after the data has been successfully saved. */
285349
filterSavedSuccessfully() {

packages/components/popover/popover.component.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
AfterViewInit,
1515
ChangeDetectionStrategy,
1616
Component,
17+
DestroyRef,
1718
Directive,
1819
ElementRef,
1920
EventEmitter,
@@ -374,6 +375,11 @@ export class KbqPopoverTrigger extends KbqPopUpTrigger<KbqPopoverComponent> impl
374375
return this.trigger.includes(PopUpTriggers.Click);
375376
}
376377

378+
/** @docs-private */
379+
get instanceDestroyRef(): DestroyRef {
380+
return this.instance.destroyRef;
381+
}
382+
377383
@Input() backdropClass: string = 'cdk-overlay-transparent-backdrop';
378384

379385
// @TODO add realization for arrow (#DS-2514)

packages/components/title/title.directive.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ export class KbqTitleDirective extends KbqTooltipTrigger implements AfterViewIni
4444
/** For special cases where the difference is a fraction of a pixel */
4545
if (
4646
!this.isVerticalOverflown &&
47-
(this.child.scrollWidth === 0 || this.parent.offsetWidth === this.child.scrollWidth)
47+
(this.child.scrollWidth === 0 || this.parent?.offsetWidth === this.child.scrollWidth)
4848
) {
4949
if (this.hasOnlyText) {
5050
const wrapper = this.renderer.createElement('span');
@@ -66,7 +66,7 @@ export class KbqTitleDirective extends KbqTooltipTrigger implements AfterViewIni
6666
}
6767

6868
get isHorizontalOverflown(): boolean {
69-
return this.parent.offsetWidth < this.child.scrollWidth;
69+
return this.parent?.offsetWidth < this.child.scrollWidth;
7070
}
7171

7272
get isVerticalOverflown(): boolean {

tools/public_api_guard/components/filter-bar.api.md

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import { DestroyRef } from '@angular/core';
1212
import { ElementRef } from '@angular/core';
1313
import { EventEmitter } from '@angular/core';
1414
import { FlatTreeControl } from '@koobiq/components/tree';
15+
import { FocusMonitor } from '@angular/cdk/a11y';
16+
import { FocusOrigin } from '@angular/cdk/a11y';
1517
import { FormControl } from '@angular/forms';
1618
import { FormGroup } from '@angular/forms';
1719
import * as i0 from '@angular/core';
@@ -21,6 +23,7 @@ import { InputSignalWithTransform } from '@angular/core';
2123
import { KbqButton } from '@koobiq/components/button';
2224
import { KbqButtonStyles } from '@koobiq/components/button';
2325
import { KbqComponentColors } from '@koobiq/components/core';
26+
import { KbqDropdownTrigger } from '@koobiq/components/dropdown';
2427
import { KbqListSelection } from '@koobiq/components/list';
2528
import { KbqLocaleService } from '@koobiq/components/core';
2629
import { KbqOption } from '@koobiq/components/core';
@@ -210,6 +213,9 @@ export class KbqFilterBar {
210213
// @public (undocumented)
211214
export class KbqFilterBarButton {
212215
constructor();
216+
protected readonly filters: KbqFilters;
217+
// (undocumented)
218+
saveFocusedElement(): void;
213219
// (undocumented)
214220
static ɵdir: i0.ɵɵDirectiveDeclaration<KbqFilterBarButton, "[kbqFilterBarButton]", never, {}, {}, never, never, true, never>;
215221
// (undocumented)
@@ -289,9 +295,18 @@ export class KbqFilterReset {
289295
export class KbqFilters implements OnInit {
290296
constructor();
291297
// (undocumented)
292-
closePopover: () => void;
298+
closePopover: (restoreFocus?: boolean) => void;
293299
protected readonly colors: typeof KbqComponentColors;
300+
protected readonly destroyRef: DestroyRef;
301+
// (undocumented)
302+
protected dropdown: KbqDropdownTrigger;
303+
protected readonly elementRef: ElementRef<any>;
294304
get filter(): KbqFilter | null;
305+
// (undocumented)
306+
protected filterActionsButton: KbqButton;
307+
// (undocumented)
308+
protected filterActionsDropdown: KbqDropdownTrigger;
309+
get filterActionsDropdownOpened(): boolean;
295310
protected readonly filterBar: KbqFilterBar;
296311
filteredOptions: Observable<KbqFilter[]>;
297312
filterName: FormControl<string | null>;
@@ -301,11 +316,17 @@ export class KbqFilters implements OnInit {
301316
filterSavedUnsuccessfully(error?: KbqSaveFilterError): void;
302317
// (undocumented)
303318
filterSavingErrorText: string;
319+
// (undocumented)
320+
focusedElementBeforeIs(button: KbqButton): boolean;
321+
protected readonly focusMonitor: FocusMonitor;
322+
get focusOrigin(): FocusOrigin;
304323
get isEmpty(): boolean;
305324
// (undocumented)
306325
isSaving: boolean;
307326
get localeData(): any;
308327
// (undocumented)
328+
protected mainButton: KbqButton;
329+
// (undocumented)
309330
ngOnInit(): void;
310331
readonly onChangeFilter: EventEmitter<KbqSaveFilterEvent>;
311332
onDropdownOpen(): void;
@@ -321,19 +342,27 @@ export class KbqFilters implements OnInit {
321342
// (undocumented)
322343
openSaveAsNewFilterPopover(): void;
323344
protected readonly placements: typeof PopUpPlacements;
345+
// (undocumented)
346+
protected popover: KbqPopoverTrigger;
324347
get popoverHeader(): string;
325348
popoverSize: PopUpSizes;
326349
// (undocumented)
327350
preparePopover(): void;
328351
// (undocumented)
352+
removeFilter(): void;
353+
// (undocumented)
329354
resetFilterChanges(): void;
330355
// (undocumented)
331356
restoreFocus(): void;
332357
// (undocumented)
333358
saveAsNew(event?: Event): void;
334359
// (undocumented)
335360
saveChanges(): void;
361+
// (undocumented)
362+
saveFocusedElement(button?: KbqButton): void;
336363
saveNewFilter: boolean;
364+
// (undocumented)
365+
protected saveNewFilterButton: KbqButton;
337366
searchControl: UntypedFormControl;
338367
searchKeydownHandler(event: KeyboardEvent): void;
339368
// (undocumented)

0 commit comments

Comments
 (0)