diff --git a/apps/datafeeder/src/app/app.module.ts b/apps/datafeeder/src/app/app.module.ts index 19bb95c30f..9af9382362 100644 --- a/apps/datafeeder/src/app/app.module.ts +++ b/apps/datafeeder/src/app/app.module.ts @@ -36,6 +36,7 @@ import { SummarizeIllustrationComponent } from './presentation/components/svg/su import { SummarizeBackgroundComponent } from './presentation/components/svg/summarize-background/summarize-background.component' import { DATAFEEDER_STATE_KEY, reducer } from './store/datafeeder.reducer' import { FeatureAuthModule } from '@geonetwork-ui/feature/auth' +import { MatIconModule } from '@angular/material/icon' export function apiConfigurationFactory() { return new Configuration({ @@ -72,6 +73,7 @@ export function apiConfigurationFactory() { UiInputsModule, UiWidgetsModule, HttpClientModule, + MatIconModule, UtilI18nModule, FeatureEditorModule, ApiModule.forRoot(apiConfigurationFactory), diff --git a/apps/datafeeder/src/app/presentation/components/data-import-validation-map-panel/data-import-validation-map-panel.component.html b/apps/datafeeder/src/app/presentation/components/data-import-validation-map-panel/data-import-validation-map-panel.component.html index b08e5c1475..1758fdfa98 100644 --- a/apps/datafeeder/src/app/presentation/components/data-import-validation-map-panel/data-import-validation-map-panel.component.html +++ b/apps/datafeeder/src/app/presentation/components/data-import-validation-map-panel/data-import-validation-map-panel.component.html @@ -23,6 +23,7 @@ [choices]="footerList" (selectValue)="selectValue($event)" [selected]="selectedValue" + [extraBtnClass]="'secondary min-w-full'" ariaName="search-sort-by" *ngIf="footerList.length > 0" > diff --git a/apps/datafeeder/src/index.html b/apps/datafeeder/src/index.html index 0fb328569d..2d1b4680d1 100644 --- a/apps/datafeeder/src/index.html +++ b/apps/datafeeder/src/index.html @@ -11,6 +11,10 @@ href="https://fonts.googleapis.com/css2?family=Open+Sans:wght@400;700&family=Permanent+Marker&display=swap" rel="stylesheet" /> + diff --git a/apps/datafeeder/src/styles.css b/apps/datafeeder/src/styles.css index 7a92c35275..c7ef160664 100644 --- a/apps/datafeeder/src/styles.css +++ b/apps/datafeeder/src/styles.css @@ -43,6 +43,11 @@ gn-ui-button button[type='button'].secondary { border-color: var(--color-primary); border-width: 1px; } + +gn-ui-dropdown-selector gn-ui-button button[type='button'].secondary { + border-width: 2px; +} + gn-ui-button button[type='button'].secondary:hover { background: var(--color-primary-darker); color: white; diff --git a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.html b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.html index 5542051c77..1367531e67 100644 --- a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.html +++ b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.html @@ -5,6 +5,7 @@ ', }) export class MockDropdownSelectorComponent { + @Input() selected: any @Input() choices: unknown[] @Output() selectValue = new EventEmitter() } diff --git a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.stories.ts b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.stories.ts index caa0507f30..cbe29d25b4 100644 --- a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.stories.ts +++ b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.stories.ts @@ -13,22 +13,23 @@ import { ChartViewComponent } from './chart-view.component' import { ChartComponent, UiDatavizModule } from '@geonetwork-ui/ui/dataviz' import { LoadingMaskComponent } from '@geonetwork-ui/ui/widgets' import { importProvidersFrom } from '@angular/core' -import { DropdownSelectorComponent } from '@geonetwork-ui/ui/inputs' +import { + DropdownSelectorComponent, + UiInputsModule, +} from '@geonetwork-ui/ui/inputs' import { MatProgressSpinner } from '@angular/material/progress-spinner' +import { OverlayModule } from '@angular/cdk/overlay' export default { title: 'Smart/Dataviz/ChartView', component: ChartViewComponent, decorators: [ moduleMetadata({ - declarations: [ - DropdownSelectorComponent, - LoadingMaskComponent, - MatProgressSpinner, - ], imports: [ ChartComponent, + OverlayModule, TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG), + UiInputsModule, ], }), applicationConfig({ diff --git a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.ts b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.ts index c35fe4995f..2deda3803b 100644 --- a/libs/feature/dataviz/src/lib/chart-view/chart-view.component.ts +++ b/libs/feature/dataviz/src/lib/chart-view/chart-view.component.ts @@ -12,7 +12,7 @@ import { FieldAggregation, getJsonDataItemsProxy, } from '@geonetwork-ui/data-fetcher' -import { DDChoices } from '@geonetwork-ui/ui/inputs' +import { DropdownChoice } from '@geonetwork-ui/ui/inputs' import { BehaviorSubject, combineLatest, EMPTY, Observable } from 'rxjs' import { catchError, @@ -24,10 +24,7 @@ import { tap, } from 'rxjs/operators' import { DataService } from '../service/data.service' -import { - AggregationTypes, - InputChartType, -} from '@geonetwork-ui/common/domain/dataviz-configuration.model' +import { InputChartType } from '@geonetwork-ui/common/domain/dataviz-configuration.model' import { DatasetDistribution } from '@geonetwork-ui/common/domain/record' import { TranslateService } from '@ngx-translate/core' @@ -93,7 +90,7 @@ export class ChartViewComponent { error = null errorInfo = null - typeChoices: DDChoices = [ + typeChoices: DropdownChoice[] = [ { label: 'chart.type.bar', value: 'bar' }, { label: 'chart.type.barHorizontal', value: 'bar-horizontal' }, { label: 'chart.type.line', value: 'line' }, @@ -111,7 +108,7 @@ export class ChartViewComponent { { label: 'chart.aggregation.min', value: 'min' }, { label: 'chart.aggregation.average', value: 'average' }, { label: 'chart.aggregation.count', value: 'count' }, - ] as DDChoices + ] as DropdownChoice[] } dataset$: Observable = this.currentLink$.pipe( diff --git a/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.html b/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.html index 0296751f56..5d01ed32aa 100644 --- a/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.html +++ b/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.html @@ -64,6 +64,7 @@ #dropdown [id]="wizardFieldConfig.id" [title]="''" + [extraBtnClass]="'secondary min-w-full'" [showTitle]="false" [choices]="dropdownChoices" [selected]="wizardFieldData" diff --git a/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.ts b/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.ts index 1bfe78ab20..fe45598346 100644 --- a/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.ts +++ b/libs/feature/editor/src/lib/components/wizard-field/wizard-field.component.ts @@ -103,7 +103,7 @@ export class WizardFieldComponent implements AfterViewInit, OnDestroy { return data ? new Date(Number(data)) : new Date() } case WizardFieldType.DROPDOWN: { - return data ? JSON.parse(data) : this.dropdownChoices[1] + return data ? JSON.parse(data) : this.dropdownChoices[0]?.value } } } diff --git a/libs/feature/record/src/lib/data-view/data-view.component.html b/libs/feature/record/src/lib/data-view/data-view.component.html index c13524d976..0a647a42d2 100644 --- a/libs/feature/record/src/lib/data-view/data-view.component.html +++ b/libs/feature/record/src/lib/data-view/data-view.component.html @@ -3,7 +3,7 @@ *ngIf="dropdownChoices$ | async as choices" [title]="'table.select.data' | translate" class="mb-7 w-auto ml-auto" - extraClass="!text-primary font-sans font-medium" + extraBtnClass="!text-primary font-sans font-medium" [choices]="choices" (selectValue)="selectLink($event)" > diff --git a/libs/feature/record/src/lib/map-view/map-view.component.html b/libs/feature/record/src/lib/map-view/map-view.component.html index f857435c17..81d0155f5d 100644 --- a/libs/feature/record/src/lib/map-view/map-view.component.html +++ b/libs/feature/record/src/lib/map-view/map-view.component.html @@ -1,7 +1,7 @@
-
+
organisation.sort.intro

- - organisation.sort.sortBy - diff --git a/libs/ui/catalog/src/lib/organisations-sort/organisations-sort.component.spec.ts b/libs/ui/catalog/src/lib/organisations-sort/organisations-sort.component.spec.ts index 25250fc3ec..a2f2758e75 100644 --- a/libs/ui/catalog/src/lib/organisations-sort/organisations-sort.component.spec.ts +++ b/libs/ui/catalog/src/lib/organisations-sort/organisations-sort.component.spec.ts @@ -1,5 +1,21 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { OrganisationsSortComponent } from './organisations-sort.component' +import { Component, EventEmitter, Input, Output } from '@angular/core' +import { TranslateModule } from '@ngx-translate/core' + +@Component({ + selector: 'gn-ui-dropdown-selector', + template: '', +}) +class DropdownSelectorMockComponent { + @Input() showTitle: unknown + @Input() choices: { + value: unknown + label: string + }[] + @Input() selected: unknown + @Output() selectValue = new EventEmitter() +} describe('OrganisationsOrderComponent', () => { let component: OrganisationsSortComponent @@ -7,7 +23,8 @@ describe('OrganisationsOrderComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [OrganisationsSortComponent], + declarations: [OrganisationsSortComponent, DropdownSelectorMockComponent], + imports: [TranslateModule.forRoot()], }).compileComponents() fixture = TestBed.createComponent(OrganisationsSortComponent) diff --git a/libs/ui/inputs/src/index.ts b/libs/ui/inputs/src/index.ts index cd80f3653e..3328a2da52 100644 --- a/libs/ui/inputs/src/index.ts +++ b/libs/ui/inputs/src/index.ts @@ -1,4 +1,6 @@ export * from './lib/dropdown-selector/dropdown-selector.component' +export * from './lib/dropdown-selector/dropdown-selector.model' +export * from './lib/dropdown-multiselect/dropdown-multiselect.component' export * from './lib/dropdown-multiselect/dropdown-multiselect.model' export * from './lib/text-input/text-input.component' export * from './lib/chips-input/chips-input.component' diff --git a/libs/ui/inputs/src/lib/button/button.component.ts b/libs/ui/inputs/src/lib/button/button.component.ts index 46764cb0d4..83770fcd80 100644 --- a/libs/ui/inputs/src/lib/button/button.component.ts +++ b/libs/ui/inputs/src/lib/button/button.component.ts @@ -55,15 +55,15 @@ export class ButtonComponent { get borderColor() { switch (this.type) { case 'default': - return 'focus:ring-4 focus:ring-gray-200' + return 'border border-gray-700 focus:ring-4 focus:ring-gray-200' case 'secondary': - return 'focus:ring-4 focus:ring-secondary-lightest' + return 'border border-secondary focus:ring-4 focus:ring-secondary-lightest' case 'primary': - return 'focus:ring-4 focus:ring-primary-lightest' + return 'border border-primary focus:ring-4 focus:ring-primary-lightest' case 'outline': - return 'border border-gray-300 -m-[1px] hover:border-primary-lighter focus:border-primary-lighter focus:ring-4 focus:ring-primary-lightest active:border-primary-darker' + return 'border border-gray-300 hover:border-primary-lighter focus:border-primary-lighter focus:ring-4 focus:ring-primary-lightest active:border-primary-darker' case 'light': - return 'focus:ring-4 focus:ring-gray-300' + return 'border border-white focus:ring-4 focus:ring-gray-300' } } diff --git a/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.stories.ts b/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.stories.ts index ff51631608..8064b038e6 100644 --- a/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.stories.ts +++ b/libs/ui/inputs/src/lib/dropdown-multiselect/dropdown-multiselect.component.stories.ts @@ -9,13 +9,14 @@ import { OverlayModule } from '@angular/cdk/overlay' import { MatCheckboxModule } from '@angular/material/checkbox' import { TranslateModule } from '@ngx-translate/core' import { MatIcon } from '@angular/material/icon' +import { ButtonComponent } from '../button/button.component' export default { title: 'Inputs/DropdownMultiselectComponent', component: DropdownMultiselectComponent, decorators: [ moduleMetadata({ - declarations: [MatIcon], + declarations: [MatIcon, ButtonComponent], imports: [OverlayModule, MatCheckboxModule, TranslateModule.forRoot()], }), componentWrapperDecorator( diff --git a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.html b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.html index 62326cf1d2..5841b35d5a 100644 --- a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.html +++ b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.html @@ -1,27 +1,73 @@ -
- - -
-
+ + {{ choice.label | translate }} + + +
+ diff --git a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.spec.ts b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.spec.ts index b66a69c21f..a4ce0b1836 100644 --- a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.spec.ts +++ b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.spec.ts @@ -1,8 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' import { TranslateModule } from '@ngx-translate/core' import { ButtonComponent } from '../button/button.component' - import { DropdownSelectorComponent } from './dropdown-selector.component' +import { OverlayModule } from '@angular/cdk/overlay' +import { MatIconModule } from '@angular/material/icon' describe('DropdownSelectorComponent', () => { let component: DropdownSelectorComponent @@ -10,7 +11,7 @@ describe('DropdownSelectorComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - imports: [TranslateModule.forRoot()], + imports: [OverlayModule, MatIconModule, TranslateModule.forRoot()], declarations: [DropdownSelectorComponent, ButtonComponent], }).compileComponents() }) @@ -24,37 +25,89 @@ describe('DropdownSelectorComponent', () => { { label: 'B', value: 'b' }, { label: 'C', value: 'c' }, ] + fixture.detectChanges() }) it('should create', () => { - fixture.detectChanges() expect(component).toBeTruthy() }) - describe('items array', () => { - let choicesEl - let selectEl + describe('items selection', () => { + let emitted beforeEach(() => { component.selected = 'b' - fixture.detectChanges() - choicesEl = fixture.nativeElement.querySelectorAll('option') - selectEl = fixture.nativeElement.querySelector('select') + emitted = null + component.selectValue.subscribe((v) => (emitted = v)) }) - it('shows one element per item in the dropdown', () => { - expect(choicesEl.length).toBe(component.choices.length) + describe('when clicking an item with selectedValueExpectedAsObject', () => { + it('emits the correct item as Json object', () => { + component.onSelectValue({ label: 'A', value: 'a' }) + expect(emitted).toEqual('a') + }) }) - it('displays the active element as such', () => { - expect(selectEl.value).toBe('b') - expect(choicesEl[0].selected).toBeFalsy() - expect(choicesEl[1].selected).toBeTruthy() - expect(choicesEl[2].selected).toBeFalsy() + + describe('when an existing value is provided', () => { + beforeEach(() => { + component.selected = 'b' + }) + it('selects the corresponding choice', () => { + expect(component.selectedChoice).toEqual({ label: 'B', value: 'b' }) + }) }) - it('emits the value of the clicked item', () => { - let emitted - component.selectValue.subscribe((v) => (emitted = v)) - selectEl.value = component.choices[0].value - selectEl.dispatchEvent(new Event('change')) - expect(emitted).toBe(component.choices[0].value) + + describe('when no selected value is provided', () => { + beforeEach(() => { + component.selected = undefined + }) + it('selects the first choice', () => { + expect(component.selectedChoice).toEqual({ label: 'A', value: 'a' }) + }) + }) + + describe('when the selected value is not part of the choices', () => { + beforeEach(() => { + component.selected = 'blarg' + }) + it('selects the first choice', () => { + expect(component.selectedChoice).toEqual({ label: 'A', value: 'a' }) + }) + }) + }) + + describe('overlay sizing', () => { + describe('width', () => { + beforeEach(() => { + const originEl: HTMLElement = + component.overlayOrigin.elementRef.nativeElement + originEl.getBoundingClientRect = () => + ({ + width: 25, + height: 20, + } as any) + component.openOverlay() + }) + it('sets the width according to the toggle element', () => { + expect(component.overlayWidth).toBe('25px') + }) + }) + describe('max height (with maxRows set)', () => { + beforeEach(() => { + component.maxRows = 10 + component.openOverlay() + }) + it('sets the max height according to the max rows input', () => { + expect(component.overlayMaxHeight).toMatch('350px') + }) + }) + describe('max height (with maxRows unset)', () => { + beforeEach(() => { + component.maxRows = undefined + component.openOverlay() + }) + it('sets the max height according to the max rows input', () => { + // we don't need the exact measurement, just to make sure it's an actual value + expect(component.overlayMaxHeight).toBe('none') + }) }) }) }) diff --git a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.stories.ts b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.stories.ts index 99c1377c0e..5f4f3bbcea 100644 --- a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.stories.ts +++ b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.stories.ts @@ -1,30 +1,38 @@ import { applicationConfig, + componentWrapperDecorator, Meta, moduleMetadata, StoryObj, } from '@storybook/angular' import { DropdownSelectorComponent } from './dropdown-selector.component' +import { OverlayModule } from '@angular/cdk/overlay' import { TranslateModule } from '@ngx-translate/core' import { TRANSLATE_DEFAULT_CONFIG, UtilI18nModule, } from '@geonetwork-ui/util/i18n' +import { MatIcon } from '@angular/material/icon' +import { ButtonComponent } from '../button/button.component' +import { importProvidersFrom } from '@angular/core' export default { title: 'Inputs/DropdownSelectorComponent', component: DropdownSelectorComponent, decorators: [ moduleMetadata({ - declarations: [], - imports: [ - UtilI18nModule, - TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG), - ], + declarations: [MatIcon, ButtonComponent], + imports: [UtilI18nModule, OverlayModule, TranslateModule], }), applicationConfig({ - providers: [], + providers: [ + importProvidersFrom(TranslateModule.forRoot(TRANSLATE_DEFAULT_CONFIG)), + ], }), + componentWrapperDecorator( + (story) => + `
${story}
` + ), ], } as Meta @@ -38,11 +46,11 @@ export const Primary: StoryObj = { value: 'choice1', }, { - label: 'My Choice 2', + label: 'My Choice 2, second choice', value: 'choice2', }, { - label: 'My Choice 3', + label: 'My Choice 3, very very very very very very long text', value: 'choice3', }, ], diff --git a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.ts b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.ts index 8d37d2e9f5..28364f421a 100644 --- a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.ts +++ b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.component.ts @@ -1,16 +1,24 @@ import { - AfterViewInit, + CdkConnectedOverlay, + CdkOverlayOrigin, + ConnectedPosition, +} from '@angular/cdk/overlay' +import { ChangeDetectionStrategy, Component, + ElementRef, EventEmitter, Input, + OnInit, Output, + QueryList, + ViewChild, + ViewChildren, } from '@angular/core' +import { firstValueFrom } from 'rxjs' +import { DropdownChoice } from './dropdown-selector.model' -export type DDChoices = Array<{ - label: string - value: string -}> +const DEFAULT_ROW_NUMBERS = 6 @Component({ selector: 'gn-ui-dropdown-selector', @@ -18,20 +26,156 @@ export type DDChoices = Array<{ styleUrls: ['./dropdown-selector.component.css'], changeDetection: ChangeDetectionStrategy.OnPush, }) -export class DropdownSelectorComponent { +export class DropdownSelectorComponent implements OnInit { @Input() title: string @Input() showTitle = true @Input() ariaName: string - @Input() choices: DDChoices - @Input() selected: any - @Input() extraClass = '' - @Output() selectValue = new EventEmitter() + @Input() choices: Array + @Input() selected: DropdownChoice['value'] + @Input() maxRows: number + @Input() extraBtnClass = '' + @Input() minWidth = '' + @Output() selectValue = new EventEmitter() + @ViewChild('overlayOrigin') overlayOrigin: CdkOverlayOrigin + @ViewChild(CdkConnectedOverlay) overlay: CdkConnectedOverlay + overlayOpen = false + overlayWidth = 'auto' + overlayMaxHeight = 'none' + overlayPositions: ConnectedPosition[] = [ + { + originX: 'start', + originY: 'bottom', + overlayX: 'start', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + ] + @ViewChildren('choiceInputs', { read: ElementRef }) + choiceInputs: QueryList + + get selectedChoice(): DropdownChoice { + return ( + this.choices.find((choice) => choice.value === this.selected) ?? + this.choices[0] + ) + } get id() { return this.title.toLowerCase().replace(/[^a-z]+/g, '-') } - isSelected(choice) { - return choice.value === this.selected + getChoiceLabel(): string { + return this.selectedChoice?.label + } + + ngOnInit(): void { + if (!this.maxRows) this.maxRows = DEFAULT_ROW_NUMBERS + if (!this.choices || this.choices.length === 0) { + this.choices = [] + } + } + + isSelected(choice: DropdownChoice) { + return choice === this.selectedChoice + } + + onSelectValue(choice: DropdownChoice) { + this.closeOverlay() + this.selected = choice.value + this.selectValue.emit(this.selected) + } + + openOverlay() { + this.overlayWidth = + this.overlayOrigin.elementRef.nativeElement.getBoundingClientRect() + .width + 'px' + this.overlayMaxHeight = this.maxRows + ? `${this.maxRows * 29 + 60}px` + : 'none' + this.overlayOpen = true + return Promise.all([ + firstValueFrom(this.overlay.attach), + firstValueFrom(this.choiceInputs.changes), + ]) + } + + closeOverlay() { + this.overlayOpen = false + } + + focusFirstItem() { + this.choiceInputs.get(0).nativeElement.focus() + } + + focusLastItem() { + this.choiceInputs.get(this.choiceInputs.length - 1).nativeElement.focus() + } + + async handleTriggerKeydown(event: KeyboardEvent) { + const keyCode = event.code + const isOpenKey = + keyCode === 'ArrowDown' || + keyCode === 'ArrowUp' || + keyCode === 'ArrowLeft' || + keyCode === 'ArrowRight' || + keyCode === 'Enter' || + keyCode === 'Space' + const isCloseKey = keyCode === 'Escape' + if (isOpenKey) { + event.preventDefault() + if (!this.overlayOpen) { + await this.openOverlay() + } + if (keyCode === 'ArrowLeft' || keyCode === 'ArrowUp') this.focusLastItem() + else this.focusFirstItem() + } else if (this.overlayOpen && isCloseKey) { + event.preventDefault() + this.closeOverlay() + } + } + + handleOverlayKeydown(event: KeyboardEvent) { + if (!this.overlayOpen) return + const keyCode = event.code + if (keyCode === 'ArrowDown' || keyCode === 'ArrowRight') { + event.preventDefault() + this.shiftItemFocus(1) + } else if (keyCode === 'ArrowLeft' || keyCode === 'ArrowUp') { + event.preventDefault() + this.shiftItemFocus(-1) + } else if (keyCode === 'Escape') { + this.closeOverlay() + } + } + + shiftItemFocus(shift: number) { + const index = this.focusedIndex + if (index === -1) return + const max = this.choiceInputs.length + // modulo, see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/Remainder + const newIndex = (((index + shift) % max) + max) % max + this.choiceInputs.get(newIndex).nativeElement.focus() + } + + get focusedIndex(): number | -1 { + return this.choiceInputs.reduce( + (prev, curr, curIndex) => + curr.nativeElement === document.activeElement ? curIndex : prev, + -1 + ) + } + + selectIfEnter(event: KeyboardEvent, choice: DropdownChoice) { + if (event.code === 'Enter') { + event.preventDefault() + this.onSelectValue(choice) + } } } diff --git a/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.model.ts b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.model.ts new file mode 100644 index 0000000000..1eb2cb10b8 --- /dev/null +++ b/libs/ui/inputs/src/lib/dropdown-selector/dropdown-selector.model.ts @@ -0,0 +1,5 @@ +// FIXME: this should support more than string values, and match the multiselect choice model +export interface DropdownChoice { + value: unknown + label: string +}