1
0
mirror of https://github.com/bitwarden/browser synced 2026-02-24 08:33:29 +00:00

[PM-31496] Reports back button placement (#18706)

* place back button fixed at bottom right

* fix type errors

* add the new button logic to org reports also

* fix: restore keyboard focus for reports back button in CDK overlay

  The CDK Overlay renders outside the cdkTrapFocus boundary, making the
  floating "Back to reports" button unreachable via Tab. Add a focus bridge
  element that intercepts Tab and programmatically redirects focus to the
  overlay button, with a return handler to cycle focus back into the page.
This commit is contained in:
Alex
2026-02-19 17:43:51 -08:00
committed by GitHub
parent 702e6086b9
commit 3663574113
6 changed files with 187 additions and 26 deletions

View File

@@ -1,3 +1,4 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { NgModule } from "@angular/core";
import { ReportsSharedModule } from "../../../dirt/reports";
@@ -8,7 +9,13 @@ import { OrganizationReportingRoutingModule } from "./organization-reporting-rou
import { ReportsHomeComponent } from "./reports-home.component";
@NgModule({
imports: [SharedModule, ReportsSharedModule, OrganizationReportingRoutingModule, HeaderModule],
imports: [
SharedModule,
OverlayModule,
ReportsSharedModule,
OrganizationReportingRoutingModule,
HeaderModule,
],
declarations: [ReportsHomeComponent],
})
export class OrganizationReportingModule {}

View File

@@ -8,9 +8,26 @@
<router-outlet></router-outlet>
<div class="tw-mt-4">
<a bitButton routerLink="./" *ngIf="!(homepage$ | async)">
<i class="bwi bwi-angle-left" aria-hidden="true"></i>
{{ "backToReports" | i18n }}
</a>
</div>
@if (!(homepage$ | async)) {
<!-- Focus bridge: redirects Tab to the overlay back button (which is outside cdkTrapFocus) -->
<span
tabindex="0"
aria-hidden="true"
class="tw-sr-only"
(keydown.tab)="focusOverlayButton($event)"
></span>
}
<ng-template #backButtonTemplate>
<div class="tw-p-3 tw-rounded-lg tw-opacity-90">
<a
bitButton
buttonType="primary"
routerLink="./"
tabindex="0"
(keydown.tab)="returnFocusToPage($event)"
>
{{ "backToReports" | i18n }}
</a>
</div>
</ng-template>

View File

@@ -1,6 +1,18 @@
// FIXME: Update this file to be type safe and remove this and next line
// @ts-strict-ignore
import { Component, OnInit } from "@angular/core";
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { TemplatePortal } from "@angular/cdk/portal";
import {
AfterViewInit,
Component,
inject,
OnDestroy,
OnInit,
TemplateRef,
viewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { ActivatedRoute, NavigationEnd, Router } from "@angular/router";
import { filter, map, Observable, startWith, concatMap, firstValueFrom } from "rxjs";
@@ -21,16 +33,30 @@ import { ReportVariant, reports, ReportType, ReportEntry } from "../../../dirt/r
templateUrl: "reports-home.component.html",
standalone: false,
})
export class ReportsHomeComponent implements OnInit {
export class ReportsHomeComponent implements OnInit, AfterViewInit, OnDestroy {
reports$: Observable<ReportEntry[]>;
homepage$: Observable<boolean>;
private readonly backButtonTemplate =
viewChild.required<TemplateRef<unknown>>("backButtonTemplate");
private overlayRef: OverlayRef | null = null;
private overlay = inject(Overlay);
private viewContainerRef = inject(ViewContainerRef);
constructor(
private route: ActivatedRoute,
private organizationService: OrganizationService,
private accountService: AccountService,
private router: Router,
) {}
) {
this.router.events
.pipe(
takeUntilDestroyed(),
filter((event) => event instanceof NavigationEnd),
)
.subscribe(() => this.updateOverlay());
}
async ngOnInit() {
this.homepage$ = this.router.events.pipe(
@@ -51,6 +77,46 @@ export class ReportsHomeComponent implements OnInit {
);
}
ngAfterViewInit(): void {
this.updateOverlay();
}
ngOnDestroy(): void {
this.overlayRef?.dispose();
}
returnFocusToPage(event: Event): void {
if ((event as KeyboardEvent).shiftKey) {
return; // Allow natural Shift+Tab behavior
}
event.preventDefault();
const firstFocusable = document.querySelector(
"[cdktrapfocus] a:not([tabindex='-1'])",
) as HTMLElement;
firstFocusable?.focus();
}
focusOverlayButton(event: Event): void {
if ((event as KeyboardEvent).shiftKey) {
return; // Allow natural Shift+Tab behavior
}
event.preventDefault();
const button = this.overlayRef?.overlayElement?.querySelector("a") as HTMLElement;
button?.focus();
}
private updateOverlay(): void {
if (this.isReportsHomepageRouteUrl(this.router.url)) {
this.overlayRef?.dispose();
this.overlayRef = null;
} else if (!this.overlayRef) {
this.overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global().bottom("20px").right("32px"),
});
this.overlayRef.attach(new TemplatePortal(this.backButtonTemplate(), this.viewContainerRef));
}
}
private buildReports(productType: ProductTierType): ReportEntry[] {
const reportRequiresUpgrade =
productType == ProductTierType.Free ? ReportVariant.RequiresUpgrade : ReportVariant.Enabled;

View File

@@ -1,11 +1,25 @@
<router-outlet></router-outlet>
<div class="tw-flex tw-flex-wrap tw-gap-4 tw-mt-4">
<div class="tw-w-full">
@if (!homepage) {
<a bitButton routerLink="./">
{{ "backToReports" | i18n }}
</a>
}
@if (!homepage) {
<!-- Focus bridge: redirects Tab to the overlay back button (which is outside cdkTrapFocus) -->
<span
tabindex="0"
aria-hidden="true"
class="tw-sr-only"
(keydown.tab)="focusOverlayButton($event)"
></span>
}
<ng-template #backButtonTemplate>
<div class="tw-p-3 tw-rounded-lg tw-opacity-90">
<a
bitButton
buttonType="primary"
routerLink="./"
tabindex="0"
(keydown.tab)="returnFocusToPage($event)"
>
{{ "backToReports" | i18n }}
</a>
</div>
</div>
</ng-template>

View File

@@ -1,4 +1,14 @@
import { Component } from "@angular/core";
import { Overlay, OverlayRef } from "@angular/cdk/overlay";
import { TemplatePortal } from "@angular/cdk/portal";
import {
AfterViewInit,
Component,
inject,
OnDestroy,
TemplateRef,
viewChild,
ViewContainerRef,
} from "@angular/core";
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
import { NavigationEnd, Router } from "@angular/router";
import { filter } from "rxjs/operators";
@@ -10,20 +20,65 @@ import { filter } from "rxjs/operators";
templateUrl: "reports-layout.component.html",
standalone: false,
})
export class ReportsLayoutComponent {
export class ReportsLayoutComponent implements AfterViewInit, OnDestroy {
homepage = true;
constructor(router: Router) {
const reportsHomeRoute = "/reports";
private readonly backButtonTemplate =
viewChild.required<TemplateRef<unknown>>("backButtonTemplate");
this.homepage = router.url === reportsHomeRoute;
router.events
private overlayRef: OverlayRef | null = null;
private overlay = inject(Overlay);
private viewContainerRef = inject(ViewContainerRef);
private router = inject(Router);
constructor() {
this.router.events
.pipe(
takeUntilDestroyed(),
filter((event) => event instanceof NavigationEnd),
)
.subscribe((event) => {
this.homepage = (event as NavigationEnd).url == reportsHomeRoute;
.subscribe(() => this.updateOverlay());
}
ngAfterViewInit(): void {
this.updateOverlay();
}
ngOnDestroy(): void {
this.overlayRef?.dispose();
}
returnFocusToPage(event: Event): void {
if ((event as KeyboardEvent).shiftKey) {
return; // Allow natural Shift+Tab behavior
}
event.preventDefault();
const firstFocusable = document.querySelector(
"[cdktrapfocus] a:not([tabindex='-1'])",
) as HTMLElement;
firstFocusable?.focus();
}
focusOverlayButton(event: Event): void {
if ((event as KeyboardEvent).shiftKey) {
return; // Allow natural Shift+Tab behavior
}
event.preventDefault();
const button = this.overlayRef?.overlayElement?.querySelector("a") as HTMLElement;
button?.focus();
}
private updateOverlay(): void {
if (this.router.url === "/reports") {
this.homepage = true;
this.overlayRef?.dispose();
this.overlayRef = null;
} else if (!this.overlayRef) {
this.homepage = false;
this.overlayRef = this.overlay.create({
positionStrategy: this.overlay.position().global().bottom("20px").right("32px"),
});
this.overlayRef.attach(new TemplatePortal(this.backButtonTemplate(), this.viewContainerRef));
}
}
}

View File

@@ -1,3 +1,4 @@
import { OverlayModule } from "@angular/cdk/overlay";
import { CommonModule } from "@angular/common";
import { NgModule } from "@angular/core";
@@ -29,6 +30,7 @@ import { ReportsSharedModule } from "./shared";
@NgModule({
imports: [
CommonModule,
OverlayModule,
SharedModule,
ReportsSharedModule,
ReportsRoutingModule,