Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions packages/components/filter-bar/pipe-add.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { ChangeDetectorRef, Component, DebugElement, inject } from '@angular/cor
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { createKeyboardEvent, dispatchEvent, ENTER, SPACE } from '@koobiq/components/core';
import {
KbqFilter,
KbqFilterBar,
Expand Down Expand Up @@ -413,6 +414,103 @@ describe('KbqPipeAdd', () => {
}));
});

describe('keyboard (Enter/Space)', () => {
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
filterBarDebugElement = fixture.debugElement.query(By.directive(KbqFilterBar));
fixture.detectChanges();
});

const pressEnterOnFirstOption = () => {
const option = document.querySelectorAll('.kbq-option')[0] as HTMLElement;

dispatchEvent(option, createKeyboardEvent('keydown', ENTER, undefined, 'Enter'));
};

const pressSpaceOnFirstOption = () => {
const option = document.querySelectorAll('.kbq-option')[0] as HTMLElement;

dispatchEvent(option, createKeyboardEvent('keydown', SPACE, undefined, ' '));
};

it('should add a pipe when Enter is pressed on a template option', fakeAsync(() => {
const filterBar = getFilterBar();
const pipeAdd = getPipeAdd();

pipeAdd.select().open();
flush();
fixture.detectChanges();

pressEnterOnFirstOption();
flush();
fixture.detectChanges();

expect(filterBar.filter!.pipes.length).toBe(1);
expect(filterBar.filter!.pipes[0].id).toBe(PIPE_TEMPLATE_ID_1);
}));

it('should add a pipe when Space is pressed on a template option', fakeAsync(() => {
const filterBar = getFilterBar();
const pipeAdd = getPipeAdd();

pipeAdd.select().open();
flush();
fixture.detectChanges();

pressSpaceOnFirstOption();
flush();
fixture.detectChanges();

expect(filterBar.filter!.pipes.length).toBe(1);
expect(filterBar.filter!.pipes[0].id).toBe(PIPE_TEMPLATE_ID_1);
}));

it('should emit onAddPipe when Enter is pressed on a template option', fakeAsync(() => {
const pipeAdd = getPipeAdd();
const spy = jest.fn();

pipeAdd.onAddPipe.subscribe(spy);

pipeAdd.select().open();
flush();
fixture.detectChanges();

pressEnterOnFirstOption();
flush();
fixture.detectChanges();

expect(spy).toHaveBeenCalledWith(expect.objectContaining({ id: PIPE_TEMPLATE_ID_1 }));
}));

it('should call filterBar.openPipe.next when Enter is pressed on an already-added option', fakeAsync(() => {
const filterBar = getFilterBar();
const pipeAdd = getPipeAdd();
const openPipeSpy = jest.spyOn(filterBar.openPipe, 'next');

// First Enter — add the pipe
pipeAdd.select().open();
flush();
fixture.detectChanges();

pressEnterOnFirstOption();
flush();
fixture.detectChanges();

// Second Enter — option is now selected, should trigger openPipe
pipeAdd.select().open();
flush();
fixture.detectChanges();

openPipeSpy.mockClear();

pressEnterOnFirstOption();
flush();
fixture.detectChanges();

expect(openPipeSpy).toHaveBeenCalledWith(PIPE_TEMPLATE_ID_1);
}));
});

describe('compareWith', () => {
beforeEach(() => {
fixture = TestBed.createComponent(TestComponent);
Expand Down
2 changes: 2 additions & 0 deletions packages/components/filter-bar/pipe-add.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { getId } from './pipes/base-pipe';
[value]="template"
[showCheckbox]="false"
(click)="addPipeFromTemplate(option)"
(keydown.enter)="addPipeFromTemplate(option)"
(keydown.space)="addPipeFromTemplate(option)"
>
{{ template.name }}
</kbq-option>
Expand Down
58 changes: 58 additions & 0 deletions packages/components/filter-bar/pipes/base-pipe.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { FocusMonitor, FocusOrigin, InputModalityDetector } from '@angular/cdk/a11y';
import { DOCUMENT } from '@angular/common';
import {
afterNextRender,
AfterViewInit,
Expand Down Expand Up @@ -47,6 +49,15 @@ export abstract class KbqBasePipe<V> implements AfterViewInit {
protected readonly changeDetectorRef = inject(ChangeDetectorRef);
/** @docs-private */
protected readonly destroyRef = inject(DestroyRef);
/** @docs-private */
protected readonly focusMonitor = inject(FocusMonitor);
/** @docs-private */
protected readonly elementRef = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly inputModalityDetector = inject(InputModalityDetector);
private readonly document = inject(DOCUMENT);

/** Last known focus origin within the pipe. Used to preserve the keyboard focus ring on restore. */
private focusOrigin: FocusOrigin = null;

/** values to select from the pipe template */
protected values;
Expand Down Expand Up @@ -89,6 +100,18 @@ export abstract class KbqBasePipe<V> implements AfterViewInit {

this.filterBar?.internalTemplatesChanges.pipe(takeUntilDestroyed()).subscribe(this.updateTemplates);

// Track the focus origin so the trigger's keyboard focus ring can be restored after
// a value is chosen. `checkChildren` captures focus on the inner trigger button.
this.focusMonitor
.monitor(this.elementRef, true)
.pipe(
filter((origin) => !!origin),
takeUntilDestroyed()
)
.subscribe((origin) => (this.focusOrigin = origin));

this.destroyRef.onDestroy(() => this.focusMonitor.stopMonitoring(this.elementRef));

afterNextRender(() => {
this.isMac = isMac();
});
Expand Down Expand Up @@ -146,6 +169,41 @@ export abstract class KbqBasePipe<V> implements AfterViewInit {
this.filterBar?.onChangePipe.next(this.data);
}

/**
* Restores focus to the pipe's trigger button after a value is chosen or the panel closes.
* Focuses via {@link FocusMonitor} with the captured origin so a keyboard-driven interaction
* keeps its focus ring, while a mouse-driven one does not.
*
* @docs-private
*/
protected restoreTriggerFocus(): void {
const active = this.document.activeElement;

// The panel may have been closed by moving focus to another interactive element
// (e.g. an outside click) — stealing focus back would break the user's intent.
// Focus inside the pipe or inside a not-yet-detached overlay is fine to take over.
if (
active &&
active !== this.document.body &&
!this.elementRef.nativeElement.contains(active) &&
!active.closest('.cdk-overlay-container')
) {
return;
}

const trigger = this.elementRef.nativeElement.querySelector<HTMLElement>(
'button:not(.kbq-pipe__remove-button)'
);

// A pipe opened right after being added from pipe-add never received focus itself,
// so `focusOrigin` is unknown there — fall back to the detected input modality.
const origin = this.focusOrigin ?? this.inputModalityDetector.mostRecentModality ?? 'program';

if (trigger) {
this.focusMonitor.focusVia(trigger, origin);
}
}

/** @docs-private */
abstract open(): void;
}
Expand Down
32 changes: 28 additions & 4 deletions packages/components/filter-bar/pipes/pipe-date.spec.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { FocusMonitor } from '@angular/cdk/a11y';
import { OverlayContainer } from '@angular/cdk/overlay';
import { ChangeDetectorRef, Component, DebugElement, inject, LOCALE_ID } from '@angular/core';
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
Expand Down Expand Up @@ -393,11 +394,12 @@ describe('KbqPipeDateComponent', () => {
setupSinglePipe({ value: null });
});

it('should set data.value, emit onChangePipe and hide popover', () => {
it('should set data.value, emit onChangePipe, hide popover and restore focus', fakeAsync(() => {
const component = getPipeComponent();
const filterBar = getFilterBar();
const spy = jest.fn();
const hide = jest.fn();
const focusViaSpy = jest.spyOn(TestBed.inject(FocusMonitor), 'focusVia');

asInternal(component).popover = () => ({ hide });
filterBar.onChangePipe.subscribe(spy);
Expand All @@ -407,20 +409,25 @@ describe('KbqPipeDateComponent', () => {
expect(component.data.value).toEqual({ name: 'test', start: '', end: '' });
expect(spy).toHaveBeenCalledWith(component.data);
expect(hide).toHaveBeenCalled();
});

flush();

expect(focusViaSpy).toHaveBeenCalledWith(expect.any(HTMLButtonElement), expect.anything());
}));
});

describe('onApplyPeriod', () => {
beforeEach(() => {
setupSinglePipe({ value: null });
});

it('should save custom period as ISO strings and emit onChangePipe', () => {
it('should save custom period as ISO strings, emit onChangePipe and restore focus', fakeAsync(() => {
const component = getPipeComponent();
const internal = asInternal(component);
const filterBar = getFilterBar();
const spy = jest.fn();
const hide = jest.fn();
const focusViaSpy = jest.spyOn(TestBed.inject(FocusMonitor), 'focusVia');
const start = adapter.today().minus({ days: 5 });
const end = adapter.today();

Expand All @@ -439,7 +446,11 @@ describe('KbqPipeDateComponent', () => {
});
expect(spy).toHaveBeenCalledWith(component.data);
expect(hide).toHaveBeenCalled();
});

flush();

expect(focusViaSpy).toHaveBeenCalledWith(expect.any(HTMLButtonElement), expect.anything());
}));
});

describe('disabled', () => {
Expand Down Expand Up @@ -573,6 +584,19 @@ describe('KbqPipeDateComponent', () => {

expect(spy).toHaveBeenCalledWith(component.data);
});

it('should focus the period list when the popover opens so Enter can pick a preset', fakeAsync(() => {
const component = getPipeComponent();
const focus = jest.fn();

asInternal(component).isListMode = true;
asInternal(component).listSelection = () => ({ focus });

component.popover().visibleChange.emit(true);
flush();

expect(focus).toHaveBeenCalled();
}));
});

describe('onClear', () => {
Expand Down
28 changes: 20 additions & 8 deletions packages/components/filter-bar/pipes/pipe-date.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@ import { KbqListModule, KbqListSelection } from '@koobiq/components/list';
import { KbqPopoverModule, KbqPopoverTrigger } from '@koobiq/components/popover';
import { KbqTimepickerModule } from '@koobiq/components/timepicker';
import { KbqTitleModule } from '@koobiq/components/title';
import { filter } from 'rxjs/operators';
import { KbqDateTimeValue } from '../filter-bar.types';
import { KbqBasePipe } from './base-pipe';
import { KbqPipeButton } from './pipe-button';
Expand Down Expand Up @@ -133,19 +132,28 @@ export class KbqPipeDateComponent<D> extends KbqBasePipe<KbqDateTimeValue> imple
/** @docs-private */
readonly popover = viewChild.required<KbqPopoverTrigger>('popover');
/** @docs-private */
listSelection = viewChild.required('listSelection', { read: KbqListSelection });
// Optional: the list only exists while the popover is open in list mode, and it is read
// in deferred callbacks that may fire after the popover has already closed.
listSelection = viewChild('listSelection', { read: KbqListSelection });
/** @docs-private */
returnButton = viewChild.required('returnButton', { read: KbqButton });

override ngAfterViewInit() {
super.ngAfterViewInit();

this.popover()
.visibleChange.pipe(
filter((visible) => !visible),
takeUntilDestroyed(this.destroyRef)
)
.subscribe(() => this.filterBar?.onClosePipe.emit(this.data));
.visibleChange.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((visible) => {
if (visible) {
// Move keyboard focus onto the period list so Enter selects a preset
// instead of the focus trap landing on the "custom period" row.
if (this.isListMode) {
setTimeout(() => this.listSelection()?.focus());
}
} else {
this.filterBar?.onClosePipe.emit(this.data);
}
});
}

/** keydown handler
Expand All @@ -170,6 +178,8 @@ export class KbqPipeDateComponent<D> extends KbqBasePipe<KbqDateTimeValue> imple
this.filterBar?.onChangePipe.next(this.data);

this.popover().hide();

setTimeout(() => this.restoreTriggerFocus());
}

onSelect(item: KbqDateTimeValue) {
Expand All @@ -179,6 +189,8 @@ export class KbqPipeDateComponent<D> extends KbqBasePipe<KbqDateTimeValue> imple
this.filterBar?.onChangePipe.next(this.data);

this.popover().hide();

setTimeout(() => this.restoreTriggerFocus());
}

showPeriod() {
Expand All @@ -197,7 +209,7 @@ export class KbqPipeDateComponent<D> extends KbqBasePipe<KbqDateTimeValue> imple
showList() {
this.isListMode = true;

setTimeout(() => this.listSelection().focus());
setTimeout(() => this.listSelection()?.focus());
this.popover().updatePosition(true);
}

Expand Down
Loading