diff --git a/src/main/webapp/app/layouts/footer/footer.component.ts b/src/main/webapp/app/layouts/footer/footer.component.ts
index 3d54d6b53f..1ebc0b2489 100644
--- a/src/main/webapp/app/layouts/footer/footer.component.ts
+++ b/src/main/webapp/app/layouts/footer/footer.component.ts
@@ -1,6 +1,6 @@
import { Component, ViewEncapsulation, inject } from '@angular/core';
import { DatePipe } from '@angular/common';
-import { Router } from '@angular/router';
+import { RouterLink } from '@angular/router';
import { toSignal } from '@angular/core/rxjs-interop';
import { VERSION } from 'app/app.constants';
import { hasText } from 'app/shared/util/text.util';
@@ -13,34 +13,20 @@ import { GitInfo } from '../profiles/profile-info.model';
selector: 'jhi-footer',
standalone: true,
templateUrl: './footer.component.html',
- imports: [DatePipe, TranslateDirective],
+ imports: [DatePipe, TranslateDirective, RouterLink],
encapsulation: ViewEncapsulation.None,
})
export default class FooterComponent {
- version: string;
+ readonly version = VERSION;
protected profileInfo;
- private router = inject(Router);
private profileService = inject(ProfileService);
constructor() {
- this.version = VERSION;
this.profileInfo = toSignal(this.profileService.getProfileInfo());
}
- navigateToImprint(): void {
- void this.router.navigate(['/imprint']);
- }
-
- navigateToPrivacy(): void {
- void this.router.navigate(['/privacy']);
- }
-
- navigateToAboutUs(): void {
- void this.router.navigate(['/about-us']);
- }
-
protected get gitInfo(): GitInfo | undefined {
const info = this.profileInfo();
return hasText(info?.ribbonEnv) && info.gitInfo ? info.gitInfo : undefined;
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/atoms/confirm-dialog/confirm-dialog.html b/src/main/webapp/app/shared/components/atoms/confirm-dialog/confirm-dialog.html
index 1103ee1061..2e27649bd5 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 6004eabacf..667a0241a9 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',
@@ -45,8 +48,11 @@ export class ConfirmDialog {
/** The button that carries out the action must never be blank, so it falls back to a generic wording. */
confirmLabel = computed(() => this.label() ?? 'button.confirm');
+ 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(() => {
@@ -55,11 +61,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(),
@@ -68,10 +91,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/atoms/image-upload-button/image-upload-button.component.html b/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.html
index 0c8cea8a33..2ce52efb20 100644
--- a/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.html
+++ b/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.html
@@ -1,4 +1,4 @@
-
+
+
diff --git a/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.ts b/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.ts
index ddc05fd5fb..747016fc8a 100644
--- a/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.ts
+++ b/src/main/webapp/app/shared/components/atoms/image-upload-button/image-upload-button.component.ts
@@ -49,7 +49,8 @@ export class ImageUploadButtonComponent {
});
readonly containerClasses = computed(() => {
- const base = 'relative rounded-lg transition-all';
+ // A button is inline-block and would shrink to its icon, so it is made to fill its grid cell like a div would.
+ const base = 'block w-full relative rounded-lg transition-all';
if (this.isUploading()) {
return `${base} opacity-50 pointer-events-none`;
diff --git a/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.html b/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.html
index 445f52d5f0..bb6ffb8254 100644
--- a/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.html
+++ b/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.html
@@ -1,12 +1,11 @@
-
@if (icon() !== undefined) {
}
-
+
diff --git a/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.ts b/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.ts
index 35ffe60512..9f3d23827b 100644
--- a/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.ts
+++ b/src/main/webapp/app/shared/components/atoms/sidebar-button/sidebar-button.component.ts
@@ -19,6 +19,9 @@ export class SidebarButtonComponent {
link = input('/');
shouldTranslate = input(true);
+ /** True when the sidebar is collapsed to a square, icon-only button. */
+ readonly isIconOnly = computed(() => this.icon() !== undefined && this.isCollapsed());
+
displayLabel = computed(() => {
this.langChange();
const value = this.label();
diff --git a/src/main/webapp/app/shared/components/atoms/string-input/string-input.component.html b/src/main/webapp/app/shared/components/atoms/string-input/string-input.component.html
index bd17b9c48f..cb1993ab42 100644
--- a/src/main/webapp/app/shared/components/atoms/string-input/string-input.component.html
+++ b/src/main/webapp/app/shared/components/atoms/string-input/string-input.component.html
@@ -52,14 +52,13 @@
{{ displayHelperTextLeft() }}
@if (helperTextRight()) {
- {{ displayHelperTextRight() }}
+ {{ displayHelperTextRight() }}
+
}
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 `