1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-16 08:13:42 +00:00

[CL-696] un-revert "various drawer improvements" + bug fix (#14887)

* Revert "Revert "[CL-622][CL-562][CL-621][CL-632] various drawer improvements …"

This reverts commit 4b32d1f9dd.

* fix virtual scroll: add .cdk-virtual-scrollable to scroll viewport target

* remove references to main el

* use directives instead of querySelector (#14950)

* remove references to main el

* wip

* banish querySelector to the shadow realm

* revert apps/ files

* Add virtual scrolling docs

Co-authored-by: Vicki League <vleague@bitwarden.com>

* add jsdoc

* run eslint

* fix skip links bug

* Update libs/components/src/layout/layout.component.ts

Co-authored-by: Vicki League <vleague@bitwarden.com>

* update tab handler

* only run on tab

* fix lint

* fix virtual scroll issue due to Angular 19 upgrade (#15193)

thanks Vicki

---------

Co-authored-by: Vicki League <vleague@bitwarden.com>
This commit is contained in:
Will Martin
2025-06-17 11:05:14 -04:00
committed by GitHub
parent 674886a28b
commit b8a1856fc6
37 changed files with 807 additions and 286 deletions

View File

@@ -1 +1,2 @@
export * from "./layout.component";
export * from "./scroll-layout.directive";

View File

@@ -1,43 +1,54 @@
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
@let mainContentId = "main-content";
<div class="tw-flex tw-w-full">
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
[id]="mainContentId"
tabindex="-1"
class="tw-overflow-auto tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
>
<ng-content></ng-content>
<div class="tw-flex tw-w-full" cdkTrapFocus>
<div
class="tw-fixed tw-z-50 tw-w-full tw-flex tw-justify-center tw-opacity-0 focus-within:tw-opacity-100 tw-pointer-events-none focus-within:tw-pointer-events-auto"
>
<nav class="tw-bg-background-alt3 tw-rounded-md tw-rounded-t-none tw-py-2 tw-text-alt2">
<a
#skipLink
bitLink
class="tw-mx-6 focus-visible:before:!tw-ring-0"
[fragment]="mainContentId"
[routerLink]="[]"
(click)="focusMainContent()"
linkType="light"
>{{ "skipToContent" | i18n }}</a
>
</nav>
</div>
<ng-content select="bit-side-nav, [slot=side-nav]"></ng-content>
<main
#main
[id]="mainContentId"
tabindex="-1"
bitScrollLayoutHost
class="tw-overflow-auto tw-max-h-screen tw-min-w-0 tw-flex-1 tw-bg-background tw-p-6 md:tw-ms-0 tw-ms-16"
>
<ng-content></ng-content>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
}
</div>
}
</main>
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
<!-- overlay backdrop for side-nav -->
@if (
{
open: sideNavService.open$ | async,
};
as data
) {
<div
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-10 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors md:tw-hidden"
[ngClass]="[data.open ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0']"
>
@if (data.open) {
<div
(click)="sideNavService.toggle()"
class="tw-pointer-events-auto tw-size-full"
></div>
}
</div>
}
</main>
</div>
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-screen md:tw-w-auto">
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
</div>
</div>

View File

@@ -1,26 +1,61 @@
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
import { PortalModule } from "@angular/cdk/portal";
import { CommonModule } from "@angular/common";
import { Component, inject } from "@angular/core";
import { Component, ElementRef, inject, viewChild } from "@angular/core";
import { RouterModule } from "@angular/router";
import { DrawerHostDirective } from "../drawer/drawer-host.directive";
import { DrawerService } from "../drawer/drawer.service";
import { LinkModule } from "../link";
import { SideNavService } from "../navigation/side-nav.service";
import { SharedModule } from "../shared";
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
@Component({
selector: "bit-layout",
templateUrl: "layout.component.html",
imports: [CommonModule, SharedModule, LinkModule, RouterModule, PortalModule],
imports: [
CommonModule,
SharedModule,
LinkModule,
RouterModule,
PortalModule,
A11yModule,
CdkTrapFocus,
ScrollLayoutHostDirective,
],
host: {
"(document:keydown.tab)": "handleKeydown($event)",
},
hostDirectives: [DrawerHostDirective],
})
export class LayoutComponent {
protected mainContentId = "main-content";
protected sideNavService = inject(SideNavService);
protected drawerPortal = inject(DrawerHostDirective).portal;
protected drawerPortal = inject(DrawerService).portal;
focusMainContent() {
document.getElementById(this.mainContentId)?.focus();
private mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
protected focusMainContent() {
this.mainContent().nativeElement.focus();
}
/**
* Angular CDK's focus trap utility is silly and will not respect focus order.
* This is a workaround to explicitly focus the skip link when tab is first pressed, if no other item already has focus.
*
* @see https://github.com/angular/components/issues/10247#issuecomment-384060265
**/
private skipLink = viewChild.required<ElementRef<HTMLElement>>("skipLink");
handleKeydown(ev: KeyboardEvent) {
if (isNothingFocused()) {
ev.preventDefault();
this.skipLink().nativeElement.focus();
}
}
}
const isNothingFocused = (): boolean => {
return [document.documentElement, document.body, null].includes(
document.activeElement as HTMLElement,
);
};

View File

@@ -0,0 +1,98 @@
import { CdkVirtualScrollable, VIRTUAL_SCROLLABLE } from "@angular/cdk/scrolling";
import {
Directive,
ElementRef,
Injectable,
OnDestroy,
OnInit,
effect,
inject,
signal,
} from "@angular/core";
import { toObservable } from "@angular/core/rxjs-interop";
import { filter, fromEvent, Observable, switchMap } from "rxjs";
/**
* A service is needed because we can't inject a directive defined in the template of a parent component. The parent's template is initialized after projected content.
**/
@Injectable({ providedIn: "root" })
export class ScrollLayoutService {
scrollableRef = signal<ElementRef<HTMLElement> | null>(null);
scrollableRef$ = toObservable(this.scrollableRef);
}
/**
* Marks the primary scrollable area of a layout component.
*
* Stores the element reference in a global service so it can be referenced by `ScrollLayoutDirective` even when it isn't a direct child of this directive.
**/
@Directive({
selector: "[bitScrollLayoutHost]",
standalone: true,
host: {
class: "cdk-virtual-scrollable",
},
})
export class ScrollLayoutHostDirective implements OnDestroy {
private ref = inject(ElementRef);
private service = inject(ScrollLayoutService);
constructor() {
this.service.scrollableRef.set(this.ref as ElementRef<HTMLElement>);
}
ngOnDestroy(): void {
this.service.scrollableRef.set(null);
}
}
/**
* Sets the scroll viewport to the element marked with `ScrollLayoutHostDirective`.
*
* `ScrollLayoutHostDirective` is set on the primary scrollable area of a layout component (`bit-layout`, `popup-page`, etc).
*
* @see "Virtual Scrolling" in Storybook.
*/
@Directive({
selector: "[bitScrollLayout]",
standalone: true,
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
})
export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit {
private service = inject(ScrollLayoutService);
constructor() {
super();
effect(() => {
const scrollableRef = this.service.scrollableRef();
if (!scrollableRef) {
// eslint-disable-next-line no-console
console.error("ScrollLayoutDirective can't find scroll host");
return;
}
this.elementRef = scrollableRef;
});
}
override elementScrolled(): Observable<Event> {
return this.service.scrollableRef$.pipe(
filter((ref) => ref !== null),
switchMap((ref) => fromEvent(ref.nativeElement, "scroll")),
);
}
override getElementRef(): ElementRef<HTMLElement> {
return this.service.scrollableRef()!;
}
override measureBoundingClientRectWithScrollOffset(
from: "left" | "top" | "right" | "bottom",
): number {
return (
this.service.scrollableRef()!.nativeElement.getBoundingClientRect()[from] -
this.measureScrollOffset(from)
);
}
}