diff --git a/eslint.config.mjs b/eslint.config.mjs index 99f9bd305c..be79d79dc2 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -268,7 +268,7 @@ export default [ 'prettier/prettier': ['error', { parser: 'angular' }], '@angular-eslint/template/alt-text': 'error', '@angular-eslint/template/button-has-type': 'error', - '@angular-eslint/template/click-events-have-key-events': 'error', + '@angular-eslint/template/click-events-have-key-events': ['error', { ignoreWithDirectives: ['jhiClickable'] }], '@angular-eslint/template/elements-content': 'off', '@angular-eslint/template/interactive-supports-focus': 'error', '@angular-eslint/template/label-has-associated-control': 'error', diff --git a/src/main/webapp/app/interview/interview-booking/selectable-slot-card/selectable-slot-card.component.html b/src/main/webapp/app/interview/interview-booking/selectable-slot-card/selectable-slot-card.component.html index 5811a5636b..3b649500c6 100644 --- a/src/main/webapp/app/interview/interview-booking/selectable-slot-card/selectable-slot-card.component.html +++ b/src/main/webapp/app/interview/interview-booking/selectable-slot-card/selectable-slot-card.component.html @@ -1,14 +1,12 @@ -
{{ timeRange() }}
@@ -31,4 +29,4 @@ }
- + diff --git a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.html b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.html index 90ff9d3a75..12f6954ab4 100644 --- a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.html +++ b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.html @@ -1,9 +1,9 @@
diff --git a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.ts b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.ts index 0914930418..37f8e00a4a 100644 --- a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.ts +++ b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-card/interviewee-card.component.ts @@ -7,6 +7,7 @@ import { map } from 'rxjs'; import { IntervieweeDTO, IntervieweeDTOStateEnum } from 'app/generated/model/interviewee-dto'; import { ButtonComponent } from 'app/shared/components/atoms/button/button.component'; import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { ClickableDirective } from 'app/shared/directives/clickable.directive'; import TranslateDirective from 'app/shared/language/translate.directive'; import { formatDate, formatTimeRange, getLocale } from 'app/shared/util/date-time.util'; import { formatFullName } from 'app/shared/util/name.util'; @@ -18,7 +19,7 @@ import { formatFullName } from 'app/shared/util/name.util'; @Component({ selector: 'jhi-interviewee-card', standalone: true, - imports: [TranslateModule, TranslateDirective, ButtonComponent, FontAwesomeModule, UserAvatarComponent], + imports: [TranslateModule, TranslateDirective, ButtonComponent, FontAwesomeModule, UserAvatarComponent, ClickableDirective], templateUrl: './interviewee-card.component.html', }) export class IntervieweeCardComponent { diff --git a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html index abc457db4c..4aa858085b 100644 --- a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html +++ b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.html @@ -55,13 +55,7 @@ -
+
{{ row.name }} diff --git a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.ts b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.ts index c3ecc20baa..6db572f95a 100644 --- a/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.ts +++ b/src/main/webapp/app/interview/interview-process-detail/interviewee-section/interviewee-section.component.ts @@ -22,6 +22,7 @@ import TranslateDirective from 'app/shared/language/translate.directive'; import { ConfirmDialog } from 'app/shared/components/atoms/confirm-dialog/confirm-dialog'; import { CheckboxComponent } from 'app/shared/components/atoms/checkbox/checkbox.component'; import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { ClickableDirective } from 'app/shared/directives/clickable.directive'; import { CancelInterviewModalComponent } from '../cancel-interview-modal/cancel-interview-modal.component'; @@ -55,6 +56,7 @@ interface ApplicantRow { CheckboxComponent, UserAvatarComponent, CancelInterviewModalComponent, + ClickableDirective, ], templateUrl: './interviewee-section.component.html', }) diff --git a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html index 23cd5198f1..d1f93c12b6 100644 --- a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html +++ b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.html @@ -76,13 +76,7 @@

{{ slotDate() }} | {{ slot @let displayName = person.user?.name ?? ((person.user?.firstName ?? '') + ' ' + (person.user?.lastName ?? '')).trim(); -
+
{{ displayName }} diff --git a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.ts b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.ts index 99d2d3fd30..1509c180b8 100644 --- a/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.ts +++ b/src/main/webapp/app/interview/interview-process-detail/slots-section/assign-applicant-modal/assign-applicant-modal.component.ts @@ -16,6 +16,7 @@ import { isVirtualLocation } from 'app/shared/util/location.util'; import { CheckboxComponent } from 'app/shared/components/atoms/checkbox/checkbox.component'; import { DynamicTableComponent } from 'app/shared/components/organisms/dynamic-table/dynamic-table.component'; import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { ClickableDirective } from 'app/shared/directives/clickable.directive'; // Modal component for assigning an applicant to an interview slot. // Displays available interviewees and allows single selection for slot assignment. @@ -32,6 +33,7 @@ import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/use CheckboxComponent, DynamicTableComponent, UserAvatarComponent, + ClickableDirective, ], templateUrl: './assign-applicant-modal.component.html', }) diff --git a/src/main/webapp/app/interview/interview-processes-overview/interview-process-card/interview-process-card.component.html b/src/main/webapp/app/interview/interview-processes-overview/interview-process-card/interview-process-card.component.html index 957a229a23..a274063818 100644 --- a/src/main/webapp/app/interview/interview-processes-overview/interview-process-card/interview-process-card.component.html +++ b/src/main/webapp/app/interview/interview-processes-overview/interview-process-card/interview-process-card.component.html @@ -1,9 +1,7 @@ -
@@ -89,4 +87,4 @@

diff --git a/src/main/webapp/app/interview/interview-processes-overview/upcoming-interviews-widget/upcoming-interview-card/upcoming-interview-card.component.ts b/src/main/webapp/app/interview/interview-processes-overview/upcoming-interviews-widget/upcoming-interview-card/upcoming-interview-card.component.ts index 80fa80bebe..23874bfb9d 100644 --- a/src/main/webapp/app/interview/interview-processes-overview/upcoming-interviews-widget/upcoming-interview-card/upcoming-interview-card.component.ts +++ b/src/main/webapp/app/interview/interview-processes-overview/upcoming-interviews-widget/upcoming-interview-card/upcoming-interview-card.component.ts @@ -3,11 +3,12 @@ import { Router } from '@angular/router'; import { UpcomingInterviewDTO } from 'app/generated/model/upcoming-interview-dto'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { ClickableDirective } from 'app/shared/directives/clickable.directive'; import dayjs from 'dayjs/esm'; @Component({ selector: 'jhi-upcoming-interview-card', - imports: [FontAwesomeModule, UserAvatarComponent], + imports: [FontAwesomeModule, UserAvatarComponent, ClickableDirective], templateUrl: './upcoming-interview-card.component.html', }) export class UpcomingInterviewCardComponent { diff --git a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html index b4b068c6d1..5cfb4af9da 100644 --- a/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html +++ b/src/main/webapp/app/job/job-creation-form/job-creation-form.component.html @@ -419,10 +419,9 @@

}
@if (showDelete) { -
+ diff --git a/src/main/webapp/app/shared/directives/clickable.directive.ts b/src/main/webapp/app/shared/directives/clickable.directive.ts new file mode 100644 index 0000000000..1e8e1ec159 --- /dev/null +++ b/src/main/webapp/app/shared/directives/clickable.directive.ts @@ -0,0 +1,66 @@ +import { Directive, ElementRef, HostListener, inject, input } from '@angular/core'; + +/** + * Adds button- or link-like keyboard activation to non-semantic elements. + * + * Use only when semantic HTML (a real `
diff --git a/src/main/webapp/app/shared/util/listbox.util.ts b/src/main/webapp/app/shared/util/listbox.util.ts new file mode 100644 index 0000000000..12616bd3a3 --- /dev/null +++ b/src/main/webapp/app/shared/util/listbox.util.ts @@ -0,0 +1,33 @@ +/** + * Works out which option a key press moves to inside a listbox. + * + * Lists that are a single tab stop need the arrow keys to do the moving, so that a long list does not + * stand between the reader and the rest of the form. Movement stops at the ends rather than wrapping, + * which keeps the edges of the list findable without watching the focus ring. + * + * @param key the value of the pressed key + * @param currentIndex the option that currently holds focus + * @param optionCount how many options the list has + * @returns the option to move to, or {@code undefined} if the key does not move focus + */ +export function nextOptionIndex(key: string, currentIndex: number, optionCount: number): number | undefined { + if (optionCount <= 0) { + return undefined; + } + + const lastIndex = optionCount - 1; + const clamped = Math.min(Math.max(currentIndex, 0), lastIndex); + + switch (key) { + case 'ArrowDown': + return Math.min(clamped + 1, lastIndex); + case 'ArrowUp': + return Math.max(clamped - 1, 0); + case 'Home': + return 0; + case 'End': + return lastIndex; + default: + return undefined; + } +} diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html index 645bfb1e2a..2dc024ee71 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.html @@ -52,29 +52,32 @@ } @if (!loading() && users().length !== 0) { - @for (user of users(); track user.id) { -
- -
-
{{ user.displayName }}
-
{{ user.email }}
+ +
+ @for (user of users(); track user.id; let index = $index) { +
+ +
+
{{ user.displayName }}
+
{{ user.email }}
+
+ +
- -
- } + } +
}
diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.ts b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.ts index e856cbdea1..a53a65db2d 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.ts +++ b/src/main/webapp/app/usermanagement/research-group/research-group-add-members/research-group-add-members.component.ts @@ -1,4 +1,4 @@ -import { Component, computed, inject, signal } from '@angular/core'; +import { Component, ElementRef, computed, inject, signal, viewChildren } from '@angular/core'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { FormsModule } from '@angular/forms'; import { PaginatorModule } from 'primeng/paginator'; @@ -14,6 +14,8 @@ import { ProgressSpinnerModule } from 'primeng/progressspinner'; import { CheckboxComponent } from 'app/shared/components/atoms/checkbox/checkbox.component'; import { InfoBoxComponent } from 'app/shared/components/atoms/info-box/info-box.component'; import { UserAvatarComponent } from 'app/shared/components/atoms/user-avatar/user-avatar.component'; +import { nextOptionIndex } from 'app/shared/util/listbox.util'; +import { injectTranslator } from 'app/shared/util/translate-signal.util'; import { formatFullName } from 'app/shared/util/name.util'; import TranslateDirective from '../../../shared/language/translate.directive'; @@ -46,8 +48,18 @@ export class ResearchGroupAddMembersComponent { searchQuery = signal(''); users = signal([]); + /** The person that carries the list's single tab stop. */ + focusedUserIndex = signal(0); + readonly userOptions = viewChildren>('userOption'); + readonly userListLabel = computed(() => this.translator.translate('researchGroup.members.listLabel')); + /** Clamped so a shorter set of search results than before still leaves the list reachable. */ + tabbableUserIndex = computed(() => { + const lastIndex = this.users().length - 1; + return lastIndex < 0 ? 0 : Math.min(Math.max(this.focusedUserIndex(), 0), lastIndex); + }); selectedUserCount = computed(() => this.selectedUsers().size); + readonly translator = injectTranslator(); userApi = inject(UserResourceApi); researchGroupApi = inject(ResearchGroupResourceApi); toastService = inject(ToastService); @@ -207,6 +219,30 @@ export class ResearchGroupAddMembersComponent { } } + /** + * Moves between people with the arrow keys and picks one with Enter or Space, so the list costs a + * single tab stop rather than one per person. + * + * @param event the key press on a person + * @param index the person the key was pressed on + * @param user the person the key was pressed on + */ + onUserKeydown(event: KeyboardEvent, index: number, user: KeycloakUserDTO): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.toggleUserSelection(user); + return; + } + + const target = nextOptionIndex(event.key, index, this.users().length); + if (target === undefined) { + return; + } + event.preventDefault(); + this.focusedUserIndex.set(target); + this.userOptions()[target]?.nativeElement.focus(); + } + isUserSelected(user: KeycloakUserDTO): boolean { if (user.id == null || user.id === '') { return false; diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-departments/department-images/department-images.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-departments/department-images/department-images.component.html index a6c251a6e8..9fe5c493b6 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-departments/department-images/department-images.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-departments/department-images/department-images.component.html @@ -30,13 +30,7 @@

Department image -
+
@@ -79,13 +73,7 @@

Department image -
+
diff --git a/src/main/webapp/app/usermanagement/research-group/research-group-images/research-group-images.component.html b/src/main/webapp/app/usermanagement/research-group/research-group-images/research-group-images.component.html index 795077d6c1..c9432625b8 100644 --- a/src/main/webapp/app/usermanagement/research-group/research-group-images/research-group-images.component.html +++ b/src/main/webapp/app/usermanagement/research-group/research-group-images/research-group-images.component.html @@ -94,13 +94,7 @@

@if (showDelete) { -
+
} diff --git a/src/main/webapp/content/scss/global.scss b/src/main/webapp/content/scss/global.scss index 7f89406f20..b46663c2f8 100644 --- a/src/main/webapp/content/scss/global.scss +++ b/src/main/webapp/content/scss/global.scss @@ -9,6 +9,25 @@ @plugin "tailwindcss-primeui"; @custom-variant dark (&:where(.docapply-dark-mode, .docapply-dark-mode *)); //dark mode configuration +/* Both rules stay in the components layer so utilities on the same element still win: + unlayered rules would outrank every layered one regardless of specificity. */ +@layer components { + /* Native +
+ `, +}) +class HostComponent { + readonly role = signal<'button' | 'link'>('button'); + readonly onClick = vi.fn(); + readonly onNestedClick = vi.fn(); +} + +function dispatch(element: HTMLElement, key: 'Enter' | ' '): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + element.dispatchEvent(event); + return event; +} + +describe('ClickableDirective', () => { + let fixture: ComponentFixture; + let host: HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ imports: [HostComponent] }).compileComponents(); + fixture = TestBed.createComponent(HostComponent); + fixture.detectChanges(); + host = fixture.debugElement.query(By.css('[data-testid="host"]')).nativeElement; + }); + + it('should apply role and tabindex defaults to the host element', () => { + expect(host.getAttribute('role')).toBe('button'); + expect(host.getAttribute('tabindex')).toBe('0'); + }); + + it('should fire the host click when Enter is pressed', () => { + const event = dispatch(host, 'Enter'); + expect(fixture.componentInstance.onClick).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('should fire the host click when Space is pressed and role is button', () => { + const event = dispatch(host, ' '); + expect(fixture.componentInstance.onClick).toHaveBeenCalledOnce(); + expect(event.defaultPrevented).toBe(true); + }); + + it('should ignore Space when role is link', () => { + fixture.componentInstance.role.set('link'); + fixture.detectChanges(); + + const event = dispatch(host, ' '); + expect(fixture.componentInstance.onClick).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + describe('nested interactive elements', () => { + let nested: HTMLElement; + + beforeEach(() => { + nested = fixture.debugElement.query(By.css('[data-testid="nested"]')).nativeElement; + }); + + it.each<['Enter' | ' ']>([['Enter'], [' ']])('should leave %s alone when it comes from a nested element', key => { + const event = dispatch(nested, key); + + expect(fixture.componentInstance.onClick).not.toHaveBeenCalled(); + expect(event.defaultPrevented).toBe(false); + }); + + it('should let a nested button fire its own click handler, leaving bubbling to the call site', () => { + nested.click(); + + expect(fixture.componentInstance.onNestedClick).toHaveBeenCalledOnce(); + expect(fixture.componentInstance.onClick).toHaveBeenCalledOnce(); + }); + }); +}); diff --git a/src/test/webapp/app/shared/pages/landing-page/jobs-preview-section/jobs-preview-section.component.spec.ts b/src/test/webapp/app/shared/pages/landing-page/jobs-preview-section/jobs-preview-section.component.spec.ts index a215da3ed1..38c23e79ca 100644 --- a/src/test/webapp/app/shared/pages/landing-page/jobs-preview-section/jobs-preview-section.component.spec.ts +++ b/src/test/webapp/app/shared/pages/landing-page/jobs-preview-section/jobs-preview-section.component.spec.ts @@ -2,13 +2,12 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { of, throwError } from 'rxjs'; import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { Router } from '@angular/router'; +import { Router, provideRouter } from '@angular/router'; import { JobsPreviewSectionComponent } from 'app/shared/pages/landing-page/jobs-preview-section/jobs-preview-section.component'; import { JobCardComponent } from 'app/job/job-overview/job-card/job-card.component'; import { JobResourceApi } from 'app/generated/api/job-resource-api'; import { JobFormDTOSubjectAreaEnum } from 'app/generated/model/job-form-dto'; import { JobCardDTOLocationEnum } from 'app/generated/model/job-card-dto'; -import { createRouterMock, provideRouterMock } from 'util/router.mock'; import { createToastServiceMock, provideToastServiceMock } from 'util/toast-service.mock'; import { provideFontAwesomeTesting } from 'util/fontawesome.testing'; import { provideTranslateMock } from 'util/translate.mock'; @@ -50,7 +49,7 @@ describe('JobsPreviewSectionComponent', () => { { provide: JobResourceApi, useValue: api }, provideToastServiceMock(mockToast), provideTranslateMock(), - provideRouterMock(createRouterMock()), + provideRouter([]), provideFontAwesomeTesting(), ], }).compileComponents(); diff --git a/src/test/webapp/app/shared/styles/btn-bare-layer.spec.ts b/src/test/webapp/app/shared/styles/btn-bare-layer.spec.ts new file mode 100644 index 0000000000..11a4254378 --- /dev/null +++ b/src/test/webapp/app/shared/styles/btn-bare-layer.spec.ts @@ -0,0 +1,20 @@ +import { readFileSync } from 'node:fs'; +import { describe, it, expect } from 'vitest'; + +/** + * `.btn-bare` resets native button chrome on elements that also carry Tailwind utilities for + * background, padding, colour and alignment. Unlayered rules outrank every layered one regardless + * of specificity, so declaring it outside a layer makes the reset beat those utilities and silently + * strips the styling from every element using it. Keeping it in the components layer, which sits + * before utilities, is what lets the utilities win. + */ +describe('btn-bare global style', () => { + const globalScss = readFileSync('src/main/webapp/content/scss/global.scss', 'utf8'); + + it('should declare btn-bare inside the components layer so utilities can override it', () => { + const componentsLayer = /@layer\s+components\s*\{([\s\S]*?)\n\}/.exec(globalScss); + + expect(componentsLayer).not.toBeNull(); + expect(componentsLayer?.[1]).toContain('.btn-bare'); + }); +}); diff --git a/src/test/webapp/app/shared/util/listbox.util.spec.ts b/src/test/webapp/app/shared/util/listbox.util.spec.ts new file mode 100644 index 0000000000..889ae8f797 --- /dev/null +++ b/src/test/webapp/app/shared/util/listbox.util.spec.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { nextOptionIndex } from 'app/shared/util/listbox.util'; + +describe('nextOptionIndex', () => { + it.each<[string, number, number]>([ + ['ArrowDown', 0, 1], + ['ArrowUp', 2, 1], + ['Home', 3, 0], + ['End', 1, 4], + ])('should move from %s at %s', (key, current, expected) => { + expect(nextOptionIndex(key, current, 5)).toBe(expected); + }); + + it('should stop at the last option rather than wrapping to the first', () => { + expect(nextOptionIndex('ArrowDown', 4, 5)).toBe(4); + }); + + it('should stop at the first option rather than wrapping to the last', () => { + expect(nextOptionIndex('ArrowUp', 0, 5)).toBe(0); + }); + + it.each<[string]>([['Enter'], [' '], ['Tab'], ['a']])('should not move for %s', key => { + expect(nextOptionIndex(key, 1, 5)).toBeUndefined(); + }); + + it.each<[number]>([[0], [-1]])('should not move when the list has %s options', count => { + expect(nextOptionIndex('ArrowDown', 0, count)).toBeUndefined(); + }); + + it('should recover when the remembered option no longer exists', () => { + expect(nextOptionIndex('ArrowDown', 9, 3)).toBe(2); + expect(nextOptionIndex('ArrowUp', -4, 3)).toBe(0); + }); +});