Fix keyup vs down bug

This commit is contained in:
shamoon 2024-04-03 01:26:29 -07:00
parent 500699d86f
commit da08a8744f
3 changed files with 121 additions and 113 deletions

View File

@ -1,65 +1,65 @@
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange"> <div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
<form class="form-inline position-relative"> <form class="form-inline position-relative">
<i-bs width="1em" height="1em" name="search"></i-bs> <i-bs width="1em" height="1em" name="search"></i-bs>
<input #searchInput class="form-control form-control-sm" type="text" name="query" <input #searchInput class="form-control form-control-sm" type="text" name="query"
autocomplete="off" placeholder="Search" aria-label="Search" i18n-placeholder autocomplete="off" placeholder="Search" aria-label="Search" i18n-placeholder
[(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keyup)="searchInputKeyDown($event)" ngbDropdownAnchor> [(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)" ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle"> <div class="position-absolute top-50 end-0 translate-middle">
<span class="badge d-none d-lg-inline text-muted position-absolute top-50 start-100 translate-middle ms-n4 fw-normal">⌘K</span> <span class="badge d-none d-lg-inline text-muted position-absolute top-50 start-100 translate-middle ms-n4 fw-normal">⌘K</span>
@if (loading) { @if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div> <div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</form>
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
(click)="primaryAction(type, item)"
(mouseenter)="onItemHover($event)">
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
<div class="text-truncate">
{{item[nameProp]}}
@if (date) {
<small class="small text-muted">{{date | customDate}}</small>
} }
</div> </div>
<div class="btn-group ms-auto">
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" </form>
(click)="primaryAction(type, item); $event.stopPropagation()"
[disabled]="disablePrimaryButton(type, item)" <ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
(mouseenter)="onButtonHover($event)"> <div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
@if (type === DataType.Document) { (click)="primaryAction(type, item)"
<i-bs width="1em" height="1em" name="pencil"></i-bs> (mouseenter)="onItemHover($event)">
<span>&nbsp;<ng-container i18n>Open</ng-container></span> <i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) { <div class="text-truncate">
<i-bs width="1em" height="1em" name="pencil"></i-bs> {{item[nameProp]}}
<span>&nbsp;<ng-container i18n>Edit</ng-container></span> @if (date) {
} @else { <small class="small text-muted">{{date | customDate}}</small>
<i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
} }
</button> </div>
@if (type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) { <div class="btn-group ms-auto">
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex" <button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="secondaryAction(type, item); $event.stopPropagation()" (click)="primaryAction(type, item); $event.stopPropagation()"
[disabled]="disableSecondaryButton(type, item)" [disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)"> (mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) { @if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="download"></i-bs> <i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span> <span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else { } @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
<i-bs width="1em" height="1em" name="pencil"></i-bs> <i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span> <span>&nbsp;<ng-container i18n>Edit</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
} }
</button> </button>
} @if (type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="secondaryAction(type, item); $event.stopPropagation()"
[disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span>
}
</button>
}
</div>
</div> </div>
</div> </ng-template>
</ng-template>
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg"> <div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg" (keydown)="dropdownKeyDown($event)">
@if (searchResults?.total === 0) { @if (searchResults?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6> <h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else { } @else {

View File

@ -169,7 +169,7 @@ describe('GlobalSearchComponent', () => {
component.primaryButtons.get(1).nativeElement, component.primaryButtons.get(1).nativeElement,
'focus' 'focus'
) )
component.handleKeyboardEvent( component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }) new KeyboardEvent('keydown', { key: 'ArrowDown' })
) )
expect(component['currentItemIndex']).toBe(1) expect(component['currentItemIndex']).toBe(1)
@ -179,12 +179,12 @@ describe('GlobalSearchComponent', () => {
component.secondaryButtons.get(1).nativeElement, component.secondaryButtons.get(1).nativeElement,
'focus' 'focus'
) )
component.handleKeyboardEvent( component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowRight' }) new KeyboardEvent('keydown', { key: 'ArrowRight' })
) )
expect(secondaryItemFocusSpy).toHaveBeenCalled() expect(secondaryItemFocusSpy).toHaveBeenCalled()
component.handleKeyboardEvent( component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowLeft' }) new KeyboardEvent('keydown', { key: 'ArrowLeft' })
) )
expect(firstItemFocusSpy).toHaveBeenCalled() expect(firstItemFocusSpy).toHaveBeenCalled()
@ -193,9 +193,7 @@ describe('GlobalSearchComponent', () => {
component.primaryButtons.get(0).nativeElement, component.primaryButtons.get(0).nativeElement,
'focus' 'focus'
) )
component.handleKeyboardEvent( component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
new KeyboardEvent('keydown', { key: 'ArrowUp' })
)
expect(component['currentItemIndex']).toBe(0) expect(component['currentItemIndex']).toBe(0)
expect(zeroItemSpy).toHaveBeenCalled() expect(zeroItemSpy).toHaveBeenCalled()
@ -203,22 +201,42 @@ describe('GlobalSearchComponent', () => {
component.searchInput.nativeElement, component.searchInput.nativeElement,
'focus' 'focus'
) )
component.handleKeyboardEvent( component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
new KeyboardEvent('keydown', { key: 'ArrowUp' })
)
expect(component['currentItemIndex']).toBe(-1) expect(component['currentItemIndex']).toBe(-1)
expect(inputFocusSpy).toHaveBeenCalled() expect(inputFocusSpy).toHaveBeenCalled()
component.handleKeyboardEvent( component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }) new KeyboardEvent('keydown', { key: 'ArrowDown' })
) )
component['currentItemIndex'] = searchResults.total - 1 component['currentItemIndex'] = searchResults.total - 1
component['setCurrentItem']() component['setCurrentItem']()
component.handleKeyboardEvent( component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(-1)
// Search input
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowUp' })
)
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' }) new KeyboardEvent('keydown', { key: 'ArrowDown' })
) )
expect(component['currentItemIndex']).toBe(0) expect(component['currentItemIndex']).toBe(0)
expect(zeroItemSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
}) })
it('should search on query debounce', fakeAsync(() => { it('should search on query debounce', fakeAsync(() => {
@ -380,27 +398,6 @@ describe('GlobalSearchComponent', () => {
expect(focusSpy).toHaveBeenCalled() expect(focusSpy).toHaveBeenCalled()
}) })
it('should handle search input keyboard nav', () => {
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
})
it('should reset on dropdown close', () => { it('should reset on dropdown close', () => {
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset') const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.onDropdownOpenChange(false) component.onDropdownOpenChange(false)

View File

@ -65,37 +65,6 @@ export class GlobalSearchComponent {
if (event.key === 'k' && (event.ctrlKey || event.metaKey)) { if (event.key === 'k' && (event.ctrlKey || event.metaKey)) {
this.searchInput.nativeElement.focus() this.searchInput.nativeElement.focus()
} }
if (
this.searchResults &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
if (this.currentItemIndex < this.searchResults.total - 1) {
event.preventDefault()
this.currentItemIndex++
this.setCurrentItem()
} else {
event.preventDefault()
this.currentItemIndex = 0
this.setCurrentItem()
}
} else if (event.key === 'ArrowUp') {
if (this.currentItemIndex > 0) {
event.preventDefault()
this.currentItemIndex--
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowRight') {
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
} else if (event.key === 'ArrowLeft') {
this.primaryButtons.get(this.domIndex).nativeElement.focus()
}
}
} }
constructor( constructor(
@ -277,6 +246,14 @@ export class GlobalSearchComponent {
event.preventDefault() event.preventDefault()
this.currentItemIndex = 0 this.currentItemIndex = 0
this.setCurrentItem() this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem()
} else if ( } else if (
event.key === 'Enter' && event.key === 'Enter' &&
this.searchResults?.total === 1 && this.searchResults?.total === 1 &&
@ -288,6 +265,40 @@ export class GlobalSearchComponent {
} }
} }
dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
if (this.currentItemIndex < this.searchResults.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
if (this.currentItemIndex > 0) {
this.currentItemIndex--
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowRight') {
event.preventDefault()
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
this.primaryButtons.get(this.domIndex).nativeElement.focus()
}
}
}
public onDropdownOpenChange(open: boolean) { public onDropdownOpenChange(open: boolean) {
if (!open) { if (!open) {
this.reset() this.reset()