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 @@
-
-
-
+
+ expand_less
+ expand_more
+
+
+
+
+
+
+
+
+ {{ 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
+}