Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feat: improving multiselect dropdown #539

Merged
merged 3 commits into from
Jul 27, 2023
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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
[maxRows]="6"
[choices]="choices$ | async"
[selected]="selected$ | async"
[allowSearch]="true"
(selectValues)="onSelectedValues($event)"
>
</gn-ui-dropdown-multiselect>
Original file line number Diff line number Diff line change
Expand Up @@ -56,28 +56,47 @@
[attr.aria-multiselectable]="true"
[attr.aria-label]="title"
(keydown)="handleOverlayKeydown($event)"
#overlayContainer
>
<div
*ngIf="allowSearch"
class="border border-gray-300 rounded mb-2 mx-2 p-2 min-h-[44px] flex flex-row flex-wrap gap-2"
class="border border-gray-300 rounded mb-2 mx-2 min-h-[44px] flex flex-row flex-wrap p-2 focus-within:rounded focus-within:border-2 focus-within:border-primary"
>
<button
type="button"
*ngFor="let selected of selectedChoices"
[title]="selected.label"
class="max-w-full bg-main text-white rounded pr-[7px] pl-2.5 flex gap-2 items-center opacity-70 hover:opacity-100 focus:opacity-100 transition-opacity"
class="max-w-full bg-main text-white rounded pr-[7px] flex gap-1 items-center opacity-70 hover:opacity-100 focus:opacity-100 transition-opacity mb-1"
(click)="select(selected, false)"
>
<div class="text-sm truncate leading-[26px]">{{ selected.label }}</div>
<div class="text-sm truncate leading-[26px] px-2">
{{ selected.label }}
</div>
<div
class="flex items-center justify-center rounded-full bg-white text-main h-[13px] w-[13px] pt-px -mt-px shrink-0"
>
<mat-icon class="!h-[12px] !w-[12px] text-[12px]"> close </mat-icon>
<mat-icon class="!h-[12px] !w-[12px] text-[12px]"> close</mat-icon>
</div>
</button>

<div *ngIf="allowSearch" class="w-[50%] relative grow shrink">
<input
#searchFieldInput
class="w-full px-2 truncate text-[14px] h-full overlaySearchInput focus:outline-none"
[(ngModel)]="searchInputValue"
[placeholder]="'multiselect.filter.placeholder' | translate"
/>
<button
*ngIf="!!searchInputValue"
class="absolute top-1/2 -translate-y-1/2 right-0 px-[7px] leading-tight clear-search-input mr-2"
(click)="clearSearchInputValue($event)"
>
<mat-icon class="!h-[10px] !w-[12px] text-[12px]"> close </mat-icon>
</button>
</div>
</div>

<label
*ngFor="let choice of choices"
*ngFor="let choice of filteredChoicesByText"
[title]="choice.label"
class="flex px-5 py-1 w-full text-gray-900 cursor-pointer hover:text-primary-darkest hover:bg-gray-50 focus-within:text-primary-darkest focus-within:bg-gray-50 transition-colors"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { MatIconModule } from '@angular/material/icon'
import { By } from '@angular/platform-browser'
import { ChangeDetectionStrategy, DebugElement } from '@angular/core'
import { ButtonComponent } from '../button/button.component'
import { TranslateModule } from '@ngx-translate/core'
import { FormsModule } from '@angular/forms'

describe('DropdownMultiselectComponent', () => {
let component: DropdownMultiselectComponent
Expand All @@ -13,7 +15,12 @@ describe('DropdownMultiselectComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DropdownMultiselectComponent, ButtonComponent],
imports: [OverlayModule, MatIconModule],
imports: [
OverlayModule,
MatIconModule,
TranslateModule.forRoot(),
FormsModule,
],
})
.overrideComponent(DropdownMultiselectComponent, {
set: { changeDetection: ChangeDetectionStrategy.Default },
Expand Down Expand Up @@ -121,6 +128,7 @@ describe('DropdownMultiselectComponent', () => {

describe('keyboard events', () => {
let triggerBtn: HTMLElement

async function dispatchEvent(el: HTMLElement, code: string) {
el.dispatchEvent(
new KeyboardEvent('keydown', {
Expand All @@ -132,6 +140,7 @@ describe('DropdownMultiselectComponent', () => {
fixture.detectChanges()
await Promise.resolve() // this makes sure that the overlay was updated
}

const getCheckboxes = () =>
component.checkboxes.map((de) => de.nativeElement) as HTMLInputElement[]
const getOverlay = () =>
Expand Down Expand Up @@ -262,4 +271,69 @@ describe('DropdownMultiselectComponent', () => {
})
})
})
describe('search', () => {
const getOverlay = () =>
document.querySelector('.overlay-container') as HTMLElement
const getOverlaySearchInput = () =>
document.querySelector('.overlaySearchInput') as HTMLElement

beforeEach(() => {
component.choices = [
{ label: 'First Choice', value: 'choice1' },
{ label: 'Second Choice', value: 'choice2' },
{ label: 'Third Choice', value: 'choice3' },
]
component.openOverlay()
fixture.detectChanges()
})

describe('no text input filter', () => {
it('displays all choices', () => {
expect(component.filteredChoicesByText.length).toBe(3)
})
it('search field is focused', () => {
expect(getOverlaySearchInput().classList).toContain(
'overlaySearchInput'
)
})
it('overlay is on top', () => {
expect(getOverlay().offsetTop).toBe(0)
})
})

describe('with matching text input filter', () => {
it('displays matching choices', () => {
component.searchInputValue = 'Sec'
expect(component.filteredChoicesByText.length).toBe(1)
expect(component.filteredChoicesByText).toContain(component.choices[1])
})
it('displays matching choices case insensitive', () => {
component.searchInputValue = 'SEC'
expect(component.filteredChoicesByText.length).toBe(1)
expect(component.filteredChoicesByText).toContain(component.choices[1])
})
})

describe('with not matching text input filter', () => {
it('displays no choices', () => {
component.searchInputValue = 'XYZ'
expect(component.filteredChoicesByText.length).toBe(0)
})
})

describe('clearing the filter with x', () => {
beforeEach(() => {
component.searchInputValue = 'XYZ'
fixture.detectChanges()
const clearBtn = fixture.debugElement.query(
By.css('.clear-search-input')
)
clearBtn.nativeElement.click()
})

it('displays all choices', () => {
expect(component.filteredChoicesByText.length).toBe(3)
})
})
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,14 @@ export class DropdownMultiselectComponent {
@Input() selected: unknown[] = []
@Input() allowSearch = true
@Input() maxRows: number
@Input() searchInputValue = ''
@Output() selectValues = new EventEmitter<unknown[]>()
@ViewChild('overlayOrigin') overlayOrigin: CdkOverlayOrigin
@ViewChild(CdkConnectedOverlay) overlay: CdkConnectedOverlay
@ViewChild('overlayContainer', { read: ElementRef })
overlayContainer: ElementRef
@ViewChild('searchFieldInput')
searchFieldInput: ElementRef<HTMLInputElement>
@ViewChildren('checkBox', { read: ElementRef })
checkboxes: QueryList<ElementRef>
overlayPositions: ConnectedPosition[] = [
Expand Down Expand Up @@ -60,11 +65,19 @@ export class DropdownMultiselectComponent {
get hasSelectedChoices() {
return this.selected.length > 0
}

get selectedChoices() {
return this.choices.filter(
(choice) => this.selected.indexOf(choice.value) > -1
)
}

get filteredChoicesByText() {
return this.choices.filter((choice) =>
choice.label.toLowerCase().includes(this.searchInputValue?.toLowerCase())
)
}

get focusedIndex(): number | -1 {
return this.checkboxes.reduce(
(prev, curr, curIndex) =>
Expand All @@ -75,6 +88,12 @@ export class DropdownMultiselectComponent {

constructor(private scrollStrategies: ScrollStrategyOptions) {}

private setFocus() {
setTimeout(() => {
this.searchFieldInput.nativeElement.focus()
}, 0)
}

openOverlay() {
this.overlayWidth =
this.overlayOrigin.elementRef.nativeElement.getBoundingClientRect()
Expand All @@ -83,15 +102,19 @@ export class DropdownMultiselectComponent {
? `${this.maxRows * 29 + 60}px`
: 'none'
this.overlayOpen = true
this.setFocus()

// this will wait for the checkboxes to be referenced and the overlay to be attached
return Promise.all([
this.overlay.attach.pipe(take(1)).toPromise(),
this.checkboxes.changes.pipe(take(1)).toPromise(),
])
}

closeOverlay() {
this.overlayOpen = false
}

async handleTriggerKeydown(event: KeyboardEvent) {
const keyCode = event.code
const isOpenKey =
Expand All @@ -112,23 +135,31 @@ export class DropdownMultiselectComponent {
this.closeOverlay()
}
}

handleOverlayKeydown(event: KeyboardEvent) {
if (!this.overlayOpen) return
const keyCode = event.code
if (keyCode === 'ArrowDown' || keyCode === 'ArrowRight') {
this.shiftItemFocus(1)
event.preventDefault()
if (document.activeElement['type'] !== 'checkbox') {
this.focusFirstItem()
} else this.shiftItemFocus(1)
} else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowUp') {
event.preventDefault()
this.shiftItemFocus(-1)
} else if (keyCode === 'Escape') {
this.closeOverlay()
}
}

focusFirstItem() {
this.checkboxes.get(0).nativeElement.focus()
}

focusLastItem() {
this.checkboxes.get(this.checkboxes.length - 1).nativeElement.focus()
}

shiftItemFocus(shift: number) {
const index = this.focusedIndex
if (index === -1) return
Expand All @@ -141,12 +172,14 @@ export class DropdownMultiselectComponent {
isSelected(choice: Choice) {
return this.selected.indexOf(choice.value) > -1
}

select(choice: Choice, selected: boolean) {
this.selected = selected
? [...this.selected.filter((v) => v !== choice.value), choice.value]
: this.selected.filter((v) => v !== choice.value)
this.selectValues.emit(this.selected)
}

toggle(choice: Choice) {
this.select(choice, !this.isSelected(choice))
}
Expand All @@ -155,4 +188,10 @@ export class DropdownMultiselectComponent {
this.selectValues.emit([])
event.stopPropagation()
}

clearSearchInputValue(event: Event) {
this.searchInputValue = ''
event.stopPropagation()
this.setFocus()
}
}
2 changes: 2 additions & 0 deletions translations/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "",
"map.navigation.message": "",
"map.select.layer": "",
"multiselect.filter.placeholder": "",
"nav.back": "",
"new record": "",
"next": "",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "",
"search.error.recordNotFound": "",
"search.field.any.placeholder": "",
"search.field.location.placeholder": "",
"search.field.sortBy": "",
"search.filters.byFormat": "",
"search.filters.byInspireKeyword": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "Loading map data...",
"map.navigation.message": "Please use CTRL + mouse (or two fingers on mobile) to navigate the map",
"map.select.layer": "Data source",
"multiselect.filter.placeholder": "Search",
"nav.back": "Back",
"new record": "New record",
"next": "next",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "An error was received",
"search.error.recordNotFound": "The record with identifier \"{ id }\" could not be found.",
"search.field.any.placeholder": "Search datasets, services and maps ...",
"search.field.location.placeholder": "",
"search.field.sortBy": "Sort by:",
"search.filters.byFormat": "Formats",
"search.filters.byInspireKeyword": "INSPIRE keyword",
Expand Down
2 changes: 2 additions & 0 deletions translations/es.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "",
"map.navigation.message": "",
"map.select.layer": "",
"multiselect.filter.placeholder": "",
"nav.back": "",
"new record": "",
"next": "",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "",
"search.error.recordNotFound": "",
"search.field.any.placeholder": "",
"search.field.location.placeholder": "",
"search.field.sortBy": "",
"search.filters.byFormat": "",
"search.filters.byInspireKeyword": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "Chargement des données...",
"map.navigation.message": "Veuillez utiliser CTRL + souris (ou deux doigts sur mobile) pour naviguer sur la carte",
"map.select.layer": "Source de données",
"multiselect.filter.placeholder": "",
"nav.back": "Retour",
"new record": "",
"next": "suivant",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "Erreur retournée",
"search.error.recordNotFound": "Cette donnée n'a pu être trouvée.",
"search.field.any.placeholder": "ex: cadastre, littoral, antennes",
"search.field.location.placeholder": "",
"search.field.sortBy": "Trier par :",
"search.filters.byFormat": "Formats",
"search.filters.byInspireKeyword": "Mot-clé INSPIRE",
Expand Down
2 changes: 2 additions & 0 deletions translations/it.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "",
"map.navigation.message": "",
"map.select.layer": "",
"multiselect.filter.placeholder": "",
"nav.back": "",
"new record": "",
"next": "",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "",
"search.error.recordNotFound": "",
"search.field.any.placeholder": "",
"search.field.location.placeholder": "",
"search.field.sortBy": "",
"search.filters.byFormat": "",
"search.filters.byInspireKeyword": "",
Expand Down
2 changes: 2 additions & 0 deletions translations/nl.json
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@
"map.loading.data": "",
"map.navigation.message": "",
"map.select.layer": "",
"multiselect.filter.placeholder": "",
"nav.back": "",
"new record": "",
"next": "",
Expand Down Expand Up @@ -194,6 +195,7 @@
"search.error.receivedError": "",
"search.error.recordNotFound": "",
"search.field.any.placeholder": "",
"search.field.location.placeholder": "",
"search.field.sortBy": "",
"search.filters.byFormat": "",
"search.filters.byInspireKeyword": "",
Expand Down
Loading
Loading