From 8d5e318a088d66ed6976474d87a28b7205056c65 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Fri, 15 May 2026 00:15:53 +0200 Subject: [PATCH 1/8] Replace manual click/keyboard wrappers with semantic HTML MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The codebase had dozens of `
`/`

`/`

` elements made clickable by hand with `role=button` + `tabindex=0` + `(click)` + `(keydown.enter)`. This converted every case that can be expressed semantically to a real `
- + 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 11dc6e3a5b..ca393a743d 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 af0a3af103..a3e246f07f 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 @@ -392,10 +392,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..93f3b67d79 --- /dev/null +++ b/src/main/webapp/app/shared/directives/clickable.directive.ts @@ -0,0 +1,49 @@ +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/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 558bcba872..877ff749c2 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 @@ -54,7 +54,7 @@ [class.bg-background-surface-alt]="isUserSelected(user)" [class.hover:bg-background-surface-alt]="isUserSelected(user)" [class.hover:bg-background-surface]="!isUserSelected(user)" - role="button" + jhiClickable tabindex="0" (click)="toggleUserSelection(user)" > 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 cae7d95f87..10b0dd0588 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 @@ -15,6 +15,7 @@ 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 { ClickableDirective } from 'app/shared/directives/clickable.directive'; import { formatFullName } from 'app/shared/util/name.util'; const I18N_BASE = 'researchGroup.members'; @@ -32,6 +33,7 @@ type UserListItem = KeycloakUserDTO & { displayName: string }; CheckboxComponent, InfoBoxComponent, UserAvatarComponent, + ClickableDirective, ], templateUrl: './research-group-add-members.component.html', }) 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..01aefb9e16 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 -
+
Department image -
+
@if (showDelete) { -
+
elements used purely as clickable containers + (cards, list rows, icon triggers). Strips default chrome so they inherit + surrounding typography and layout, while keeping their accessibility benefits. */ +.btn-bare { + appearance: none; + background: transparent; + border: 0; + padding: 0; + margin: 0; + font: inherit; + color: inherit; + text-align: inherit; + cursor: pointer; +} + /* ------------------------------- Custom Global Styles for Inputs ---------------------------------- */ diff --git a/src/test/webapp/app/job/job-overview/job-card/job-card.component.spec.ts b/src/test/webapp/app/job/job-overview/job-card/job-card.component.spec.ts index c309bfeecd..4ce39a3f95 100644 --- a/src/test/webapp/app/job/job-overview/job-card/job-card.component.spec.ts +++ b/src/test/webapp/app/job/job-overview/job-card/job-card.component.spec.ts @@ -1,5 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; -import { provideRouter, Router } from '@angular/router'; +import { provideRouter, RouterLink } from '@angular/router'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { By } from '@angular/platform-browser'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; @@ -28,15 +28,14 @@ describe('JobCardComponent', () => { }); describe('Navigation', () => { - it('should navigate to the detail page on onViewDetails()', () => { - const router = TestBed.inject(Router); - const routerNavigateSpy = vi.spyOn(router, 'navigate'); - + it('should point the card link to the job detail page', () => { fixture.componentRef.setInput('jobId', 'abc-123'); fixture.detectChanges(); - component.onViewDetails(); - expect(routerNavigateSpy).toHaveBeenCalledWith(['/job/detail/abc-123']); + const link = fixture.debugElement.query(By.directive(RouterLink)); + const routerLinkInstance = link.injector.get(RouterLink); + + expect(routerLinkInstance.href).toBe('/job/detail/abc-123'); }); }); diff --git a/src/test/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.spec.ts b/src/test/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.spec.ts index 2944705984..cca991c1ab 100644 --- a/src/test/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.spec.ts @@ -42,8 +42,8 @@ describe('SidebarButtonComponent', () => { const navigateSpy = vi.spyOn(router, 'navigate').mockRejectedValue(error); const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); - const buttonDiv = fixture.debugElement.query(de => de.nativeElement?.getAttribute?.('role') === 'button'); - buttonDiv.triggerEventHandler('click', null); + const buttonEl = fixture.debugElement.query(de => de.nativeElement?.tagName === 'BUTTON'); + buttonEl.triggerEventHandler('click', null); await fixture.whenStable(); diff --git a/src/test/webapp/app/shared/components/organisms/login/login.component.spec.ts b/src/test/webapp/app/shared/components/organisms/login/login.component.spec.ts index 7ca260a5d8..5e9cc69645 100644 --- a/src/test/webapp/app/shared/components/organisms/login/login.component.spec.ts +++ b/src/test/webapp/app/shared/components/organisms/login/login.component.spec.ts @@ -108,8 +108,10 @@ describe('Login Component', () => { it('should call orchestrator to switch to register view', () => { const switchToRegisterSpy = vi.spyOn(authOrchestrator, 'switchToRegister'); - const registerLink = fixture.debugElement.query(de => de.nativeElement?.tagName === 'A'); - registerLink.triggerEventHandler('click', null); + const registerButton = fixture.debugElement.query( + de => de.nativeElement?.tagName === 'BUTTON' && de.attributes['jhiTranslate'] === 'auth.login.texts.accountMissing.link', + ); + registerButton.triggerEventHandler('click', null); expect(switchToRegisterSpy).toHaveBeenCalledOnce(); }); diff --git a/src/test/webapp/app/shared/directives/clickable.directive.spec.ts b/src/test/webapp/app/shared/directives/clickable.directive.spec.ts new file mode 100644 index 0000000000..a3afede05a --- /dev/null +++ b/src/test/webapp/app/shared/directives/clickable.directive.spec.ts @@ -0,0 +1,61 @@ +import { Component, signal } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { describe, expect, it, vi } from 'vitest'; + +import { ClickableDirective } from 'app/shared/directives/clickable.directive'; + +@Component({ + imports: [ClickableDirective], + template: ` +
target
+ `, +}) +class HostComponent { + readonly role = signal<'button' | 'link'>('button'); + readonly onClick = vi.fn(); +} + +function dispatch(host: HTMLElement, key: 'Enter' | ' '): KeyboardEvent { + const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); + host.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); + }); +}); 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(); From f5bb288828c4738977d01520c403ce995ca4aa37 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sat, 25 Jul 2026 19:05:56 +0200 Subject: [PATCH 2/8] `Bugfix`: Keep the button reset from overriding Tailwind utilities The active and hover highlight disappeared from the sidebar items after the switch to native buttons. .btn-bare was declared outside any cascade layer while Tailwind emits its utilities inside @layer utilities. An unlayered rule outranks every layered one whatever its specificity, so the reset won against the utilities sitting on the same element and its background, padding, colour, font and text-align were applied instead of theirs. Moving the rule into the components layer, which the generated stylesheet orders before utilities, restores the intended precedence. The sidebar was the visible symptom, but the same reset was quietly beating utilities on all twelve elements using it: padding on the upload button and the slot cards, background and border on the interview process card, colour and weight on the login and registration links, alignment in the document dialog. Confirmed from the compiled stylesheet rather than by eye: .btn-bare now resolves inside the components layer while .bg-primary-hover-outlined stays in utilities. Added a guard on the declaration, since nothing else can catch this. The class is applied either way, so a component test still passes while the styling is gone, and the failure is only visible on screen. The guard was checked to fail with the rule moved back out of the layer. Verified: 2028 client tests, typecheck, eslint with no errors, a11y lint, the production build and prettier. Co-Authored-By: Claude --- src/main/webapp/content/scss/global.scss | 29 ++++++++++++------- .../app/shared/styles/btn-bare-layer.spec.ts | 20 +++++++++++++ 2 files changed, 38 insertions(+), 11 deletions(-) create mode 100644 src/test/webapp/app/shared/styles/btn-bare-layer.spec.ts diff --git a/src/main/webapp/content/scss/global.scss b/src/main/webapp/content/scss/global.scss index 5d731ef791..254f17b817 100644 --- a/src/main/webapp/content/scss/global.scss +++ b/src/main/webapp/content/scss/global.scss @@ -11,17 +11,24 @@ /* Visual reset for native +
+ `, }) class HostComponent { readonly role = signal<'button' | 'link'>('button'); readonly onClick = vi.fn(); + readonly onNestedClick = vi.fn(); } -function dispatch(host: HTMLElement, key: 'Enter' | ' '): KeyboardEvent { +function dispatch(element: HTMLElement, key: 'Enter' | ' '): KeyboardEvent { const event = new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true }); - host.dispatchEvent(event); + element.dispatchEvent(event); return event; } @@ -56,4 +62,26 @@ describe('ClickableDirective', () => { 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(); + }); + }); }); From 1bb2ff9ceb1f24c2989e1b5945d4d357df33f43c Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Tue, 28 Jul 2026 16:18:38 +0200 Subject: [PATCH 6/8] `Bugfix`: Move focus into confirmation dialogs and show it on hover-only buttons Opening a confirmation left focus behind the mask, so the dialog could not be reached by keyboard at all. PrimeNG focuses the first control of its own header, content or footer, and a headless dialog renders none of those, so there was nothing for it to focus. The dialog now moves focus to its first control itself, and hands focus back to whatever opened it on close, unless that has since been removed. Ours is the only headless dialog, so the rest already behaved. The delete button on an image card is invisible until the card is hovered. Tabbing still reached it, landing on a control the reader cannot see, so it now appears on focus as well. Both classes the review asked about are dropped. The buttons sit in flex containers that already stretch them, one through grow and one through the default stretch of a column, so w-full was doing nothing, and btn-bare already restores the inherited alignment that text-left was repeating. Co-Authored-By: Claude --- .../atoms/confirm-dialog/confirm-dialog.html | 2 +- .../atoms/confirm-dialog/confirm-dialog.ts | 39 ++++++++++++++++++- .../document-dialog/document-dialog.html | 2 +- ...esearch-group-creation-form.component.html | 2 +- .../department-images.component.html | 4 +- .../research-group-images.component.html | 2 +- .../confirm-dialog/confirm-dialog.spec.ts | 29 ++++++++++++++ 7 files changed, 73 insertions(+), 7 deletions(-) diff --git a/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.html b/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.html index aa3f131b3f..7c32465f70 100644 --- a/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.html +++ b/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.html @@ -1,6 +1,6 @@ -
+

{{ message.header }}

diff --git a/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.ts b/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.ts index a7cc346c81..b79f627d5b 100644 --- a/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.ts +++ b/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.ts @@ -1,4 +1,4 @@ -import { Component, ViewEncapsulation, computed, effect, inject, input, output } from '@angular/core'; +import { Component, ElementRef, ViewEncapsulation, computed, effect, inject, input, output, viewChild } from '@angular/core'; import { ConfirmationService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @@ -6,6 +6,9 @@ import { injectTranslator } from 'app/shared/util/translate-signal.util'; import { ButtonColor, ButtonComponent, ButtonSize, ButtonVariant } from '../button/button.component'; +/** Matches the controls a reader can tab to, in the order they appear. */ +const FOCUSABLE_SELECTOR = 'button:not([disabled]), [href], input:not([disabled]), select:not([disabled]), textarea:not([disabled])'; + @Component({ selector: 'jhi-confirm-dialog', templateUrl: './confirm-dialog.html', @@ -42,8 +45,11 @@ export class ConfirmDialog { displayHeader = computed(() => this.translator.translate(this.header(), this.shouldTranslate())); displayMessage = computed(() => this.translator.translate(this.message(), this.shouldTranslate(), this.messageParams())); + private readonly dialogContent = viewChild>('dialogContent'); + private confirmationService = inject(ConfirmationService); private translator = injectTranslator(); + private elementToRestoreFocusTo: HTMLElement | undefined; // Opens the dialog declaratively when visible becomes true private visibleEffect = effect(() => { @@ -52,11 +58,28 @@ export class ConfirmDialog { } }); + /** + * Moves focus into the dialog once it is on screen. + * + * PrimeNG focuses the first focusable element of its own content, header or footer, none of which + * exist for a headless dialog, so without this focus stays behind the mask and the buttons cannot + * be reached by keyboard at all. + */ + private moveFocusIntoDialogEffect = effect(() => { + const content = this.dialogContent()?.nativeElement; + if (content === undefined) { + return; + } + // The dialog animates in, so focusing is deferred until it can actually take focus. + setTimeout(() => content.querySelector(FOCUSABLE_SELECTOR)?.focus()); + }); + confirm(): void { this.openDialog(); } private openDialog(): void { + this.elementToRestoreFocusTo = document.activeElement instanceof HTMLElement ? document.activeElement : undefined; this.confirmationService.confirm({ message: this.displayMessage(), header: this.displayHeader(), @@ -65,10 +88,24 @@ export class ConfirmDialog { accept: () => { this.confirmed.emit(this.data()); this.closed.emit(); + this.restoreFocus(); }, reject: () => { this.closed.emit(); + this.restoreFocus(); }, }); } + + /** + * Returns focus to whatever opened the dialog, so the keyboard does not land back at the top of the page. + * The trigger may be gone by then, for example the delete button of the row that was just removed. + */ + private restoreFocus(): void { + const trigger = this.elementToRestoreFocusTo; + this.elementToRestoreFocusTo = undefined; + if (trigger?.isConnected === true) { + trigger.focus(); + } + } } diff --git a/src/main/webapp/app/shared/components/molecules/document-dialog/document-dialog.html b/src/main/webapp/app/shared/components/molecules/document-dialog/document-dialog.html index 219a1f43a5..fbc6982025 100644 --- a/src/main/webapp/app/shared/components/molecules/document-dialog/document-dialog.html +++ b/src/main/webapp/app/shared/components/molecules/document-dialog/document-dialog.html @@ -24,7 +24,7 @@
@@ -90,7 +90,7 @@

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 a0079ab4a0..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 @@ -111,7 +111,7 @@

} diff --git a/src/test/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.spec.ts b/src/test/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.spec.ts index a2a636f7e5..564464f889 100644 --- a/src/test/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.spec.ts +++ b/src/test/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.spec.ts @@ -67,6 +67,35 @@ describe('ConfirmDialog', () => { }); }); + describe('Focus Handling', () => { + it('should return focus to whatever opened it once it closes', () => { + const fixture = createFixture(); + const comp = fixture.componentInstance; + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + trigger.focus(); + + comp.confirm(); + (mockConfirmationService.confirm.mock.calls[0][0] as ConfirmArgs).accept?.(); + + expect(document.activeElement).toBe(trigger); + trigger.remove(); + }); + + it('should not throw when whatever opened it is gone by the time it closes', () => { + const fixture = createFixture(); + const comp = fixture.componentInstance; + const trigger = document.createElement('button'); + document.body.appendChild(trigger); + trigger.focus(); + + comp.confirm(); + trigger.remove(); + + expect(() => (mockConfirmationService.confirm.mock.calls[0][0] as ConfirmArgs).reject?.()).not.toThrow(); + }); + }); + describe('Confirm Method and Dialog Behaviour', () => { it('should call confirmationService.confirm with header and message when confirm() is called', () => { const fixture = createFixture(); From 7420c08b8dc04787099233ebb3b837fdad9a908f Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Tue, 28 Jul 2026 16:32:50 +0200 Subject: [PATCH 7/8] `Development`: Move through the candidate lists with the arrow keys Both lists put every person between the reader and the rest of the form, which is a long way round when a search returns twenty-five professors. Each list is now a single tab stop: the arrows move between people, Home and End jump to the ends, Enter or Space picks one, and Tab carries on to the next field. The rows say what they are as well. The lists are listboxes and the rows are options that report whether they are selected, so the choice is announced rather than inferred from a highlight. The people list allows several at once and says so. Its checkbox stopped being a second tab stop on every row. The row is the control now, and the box shows the state the row already reports, so it is no longer reachable or announced separately. Because an option can be reached without the pointer, both lists draw a focus outline that the hover tint alone was not providing. Co-Authored-By: Claude --- .../atoms/checkbox/checkbox.component.html | 2 + .../atoms/checkbox/checkbox.component.ts | 5 +- ...esearch-group-creation-form.component.html | 47 +++++++++++-------- .../research-group-creation-form.component.ts | 33 ++++++++++++- .../webapp/app/shared/util/listbox.util.ts | 33 +++++++++++++ .../research-group-add-members.component.html | 47 ++++++++++--------- .../research-group-add-members.component.ts | 40 ++++++++++++++-- src/main/webapp/i18n/de/research-group.json | 6 ++- src/main/webapp/i18n/en/research-group.json | 6 ++- .../app/shared/util/listbox.util.spec.ts | 34 ++++++++++++++ 10 files changed, 203 insertions(+), 50 deletions(-) create mode 100644 src/main/webapp/app/shared/util/listbox.util.ts create mode 100644 src/test/webapp/app/shared/util/listbox.util.spec.ts diff --git a/src/main/webapp/app/shared/components/atoms/checkbox/checkbox.component.html b/src/main/webapp/app/shared/components/atoms/checkbox/checkbox.component.html index f2e96b6035..d884a47f9a 100644 --- a/src/main/webapp/app/shared/components/atoms/checkbox/checkbox.component.html +++ b/src/main/webapp/app/shared/components/atoms/checkbox/checkbox.component.html @@ -4,6 +4,7 @@ { + /** Set to -1 where the box only shows state and something around it takes the focus. */ + tabIndex = input(0); + // Methods onCheckboxChange(event: CheckboxChangeEvent): void { const value = event.checked === true; diff --git a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html index 9eb5c9a971..0b6fee4210 100644 --- a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html +++ b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html @@ -153,26 +153,35 @@ } @if (showCandidatesList()) { - @for (user of adminProfessorCandidates(); track user.id) { - - } + + {{ user.firstName ? user.firstName.charAt(0) : '' }}{{ user.lastName ? user.lastName.charAt(0) : '' }} + + + {{ user.firstName }} {{ user.lastName }} + {{ user.email }} + {{ user.universityId }} + + + } +
@if (hasMoreAdminProfessorCandidates()) {
diff --git a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.ts b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.ts index fcb9af72bb..f89fcd268a 100644 --- a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.ts +++ b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.ts @@ -1,5 +1,7 @@ import { hasText } from 'app/shared/util/text.util'; -import { Component, computed, inject, signal } from '@angular/core'; +import { nextOptionIndex } from 'app/shared/util/listbox.util'; +import { injectTranslator } from 'app/shared/util/translate-signal.util'; +import { Component, ElementRef, computed, inject, signal, viewChildren } from '@angular/core'; import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; @@ -75,6 +77,10 @@ export class ResearchGroupCreationFormComponent { readonly ADMIN_USERS_PAGE_SIZE = 25; adminProfessorSearchQuery = signal(''); adminProfessorCandidates = signal([]); + /** The candidate that carries the list's single tab stop. */ + focusedCandidateIndex = signal(0); + readonly candidateOptions = viewChildren>('candidateOption'); + readonly candidateListLabel = computed(() => this.translator.translate('researchGroup.adminView.professorSelect.listLabel')); adminProfessorTotalCount = signal(0); adminProfessorCurrentPage = signal(0); hasMoreAdminProfessorCandidates = computed(() => this.adminProfessorCandidates().length < this.adminProfessorTotalCount()); @@ -87,6 +93,14 @@ export class ResearchGroupCreationFormComponent { () => !this.isLoadingAdminUsers() && this.isSearchQueryLongEnough() && this.adminProfessorCandidates().length === 0, ); showCandidatesList = computed(() => !this.isLoadingAdminUsers() && this.adminProfessorCandidates().length > 0); + /** + * Which candidate is reachable by tab, clamped so that a shorter set of search results than before + * still leaves the list reachable. + */ + tabbableCandidateIndex = computed(() => { + const lastIndex = this.adminProfessorCandidates().length - 1; + return lastIndex < 0 ? 0 : Math.min(Math.max(this.focusedCandidateIndex(), 0), lastIndex); + }); // School and Department data schools = signal([]); @@ -140,6 +154,7 @@ export class ResearchGroupCreationFormComponent { showConfirmDialog = signal(false); // Services + private readonly translator = injectTranslator(); private readonly fb = inject(FormBuilder); private readonly config = inject(DynamicDialogConfig, { optional: true }); private readonly ref = inject(DynamicDialogRef, { optional: true }); @@ -263,6 +278,22 @@ export class ResearchGroupCreationFormComponent { this.form.get('tumID')?.markAsTouched(); } + /** + * Moves between candidates with the arrow keys, so the list costs one tab stop rather than one per professor. + * + * @param event the key press on a candidate + * @param index the candidate the key was pressed on + */ + onCandidateKeydown(event: KeyboardEvent, index: number): void { + const target = nextOptionIndex(event.key, index, this.adminProfessorCandidates().length); + if (target === undefined) { + return; + } + event.preventDefault(); + this.focusedCandidateIndex.set(target); + this.candidateOptions()[target]?.nativeElement.focus(); + } + clearSelectedAdminProfessor(): void { this.selectedAdminProfessor.set(undefined); this.form.patchValue({ tumID: '' }); 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 877ff749c2..74e1c23ae5 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 @@ -48,29 +48,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 c3a92499d8..877cba55a0 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 { TranslateModule } from '@ngx-translate/core'; import { DynamicDialogConfig, DynamicDialogRef } from 'primeng/dynamicdialog'; import { FormsModule } from '@angular/forms'; @@ -15,7 +15,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 { ClickableDirective } from 'app/shared/directives/clickable.directive'; +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'; const I18N_BASE = 'researchGroup.members'; @@ -33,7 +34,6 @@ type UserListItem = KeycloakUserDTO & { displayName: string }; CheckboxComponent, InfoBoxComponent, UserAvatarComponent, - ClickableDirective, ], templateUrl: './research-group-add-members.component.html', }) @@ -47,8 +47,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); @@ -208,6 +218,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/i18n/de/research-group.json b/src/main/webapp/i18n/de/research-group.json index caecbdf1fa..5e5a8fe2ce 100644 --- a/src/main/webapp/i18n/de/research-group.json +++ b/src/main/webapp/i18n/de/research-group.json @@ -167,7 +167,8 @@ "detail": "Fehler beim Entfernen. Bitte versuche es erneut, oder kontaktiere uns, wenn das Problem weiterhin besteht." } }, - "noRole": "Keine Rolle" + "noRole": "Keine Rolle", + "listLabel": "Personen, die zur Suche passen" }, "adminView": { "errors": { @@ -275,7 +276,8 @@ "noResults": "Keine verfügbaren Professor:innen gefunden.", "requiredError": "Bitte wähle eine:n Professor:in aus.", "loadMore": "Mehr laden", - "changeButton": "Auswahl ändern" + "changeButton": "Auswahl ändern", + "listLabel": "Professoren, die zur Suche passen" }, "createButton": "Forschungsgruppe erstellen", "createDialog": { diff --git a/src/main/webapp/i18n/en/research-group.json b/src/main/webapp/i18n/en/research-group.json index 286c53dc2d..fc5137407c 100644 --- a/src/main/webapp/i18n/en/research-group.json +++ b/src/main/webapp/i18n/en/research-group.json @@ -167,7 +167,8 @@ "detail": "Failed to remove. Please try again, or contact us if the problem persists." } }, - "noRole": "No Role" + "noRole": "No Role", + "listLabel": "People matching your search" }, "adminView": { "errors": { @@ -275,7 +276,8 @@ "noResults": "No available professors found.", "requiredError": "Please select a professor.", "loadMore": "Load more", - "changeButton": "Change selection" + "changeButton": "Change selection", + "listLabel": "Professors matching your search" }, "createButton": "Create Research Group", "createDialog": { 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); + }); +}); From 25cc81908a45aeca269fad60705bfafad286f4b3 Mon Sep 17 00:00:00 2001 From: aniruddhzaveri Date: Sun, 9 Aug 2026 16:28:28 +0200 Subject: [PATCH 8/8] `General`: Reuse one focus indicator style for the candidate lists Replace the copy-pasted focus-visible utilities on the listbox options with a shared .focus-ring-inset class, and shorten the .btn-bare comment. Co-Authored-By: Claude Opus 5 (1M context) --- ...esearch-group-creation-form.component.html | 2 +- .../research-group-add-members.component.html | 2 +- src/main/webapp/content/scss/global.scss | 21 +++++++++++-------- 3 files changed, 14 insertions(+), 11 deletions(-) diff --git a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html index 0b6fee4210..fa0be46237 100644 --- a/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html +++ b/src/main/webapp/app/shared/components/molecules/research-group-creation-form/research-group-creation-form.component.html @@ -160,7 +160,7 @@ #candidateOption type="button" role="option" - class="btn-bare flex items-center gap-3 p-3 cursor-pointer transition-colors focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-primary" + class="btn-bare focus-ring-inset flex items-center gap-3 p-3 cursor-pointer transition-colors" [class.bg-background-surface-alt]="selectedAdminProfessor()?.id === user.id" [class.hover:bg-background-surface]="selectedAdminProfessor()?.id !== user.id" [attr.aria-selected]="selectedAdminProfessor()?.id === user.id" 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 f946349bd9..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 @@ -58,7 +58,7 @@
elements used purely as clickable containers (cards, list rows, - icon triggers). Only covers what Tailwind's preflight leaves behind: buttons come out - centred and, since v4, with a default cursor. Background, border, padding, margin, font - and colour are already reset there, so repeating them here only creates something that - can fight the utilities on the same element. - - Kept in the components layer for that same reason. Unlayered rules outrank every layered - one whatever their specificity, so declaring this outside a layer would let it beat the - utilities instead. Components sits before utilities, so utilities win. */ +/* 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