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:
@@ -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 {}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user