mirror of
https://github.com/bitwarden/browser
synced 2026-02-28 18:43:26 +00:00
Merge branch 'main' into uif/cl-958/avatar
This commit is contained in:
69
libs/components/src/a11y/router-focus-manager.mdx
Normal file
69
libs/components/src/a11y/router-focus-manager.mdx
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Meta } from "@storybook/addon-docs/blocks";
|
||||
|
||||
<Meta title="Documentation/Router Focus Management" />
|
||||
|
||||
# Router Focus Management
|
||||
|
||||
On a normal non-SPA (Single Page Application) webpage, a page navigation / route change will cause
|
||||
the full page to reload, and a user's focus is placed at the top of the page when the new page
|
||||
loads.
|
||||
|
||||
Bitwarden's Angular apps are SPAs using the Angular router to manage internal routing and page
|
||||
navigation. When the Angular router performs a page navigation / route change to another internal
|
||||
SPA route, the full page does not reload, and the user's focus does not move from the trigger
|
||||
element unless the trigger element no longer exists. There is no other built-in notification to a
|
||||
screenreader user that a navigation has occured, if the focus is not moved.
|
||||
|
||||
## Web
|
||||
|
||||
We handle router focus management in the web app by moving the user's focus at the end of a SPA
|
||||
Angular router navigation.
|
||||
|
||||
See `router-focus-manager.service.ts` for the implementation.
|
||||
|
||||
### Default behavior
|
||||
|
||||
By default, we focus the `main` element.
|
||||
|
||||
Consumers can change or opt out of the focus management using the `state` input to the
|
||||
[Angular route](https://angular.dev/api/router/RouterLink). Using `state` allows us to access the
|
||||
value between browser back/forward arrows.
|
||||
|
||||
### Change focus target
|
||||
|
||||
In template: `<a [routerLink]="route()" [state]="{ focusAfterNav: '#selector' }"></a>`
|
||||
|
||||
In typescript: `this.router.navigate([], { state: { focusAfterNav: '#selector' }})`
|
||||
|
||||
Any valid `querySelector` selector can be passed. If the element is not found, no focus management
|
||||
occurs as we cannot make the assumption that the default `main` element is the next best option.
|
||||
|
||||
Examples of where you might want to change the target:
|
||||
|
||||
- A full page navigation occurs where you do want the user to be placed at the top of the page (aka
|
||||
the body element) like a non-SPA app, such as going from Password Manager to Secrets Manager
|
||||
- A routed dialog needs to manually specify where the focus should return to once it is closed
|
||||
|
||||
### Opt out of focus management
|
||||
|
||||
In template: `<a [routerLink]="route()" [state]="{ focusAfterNav: false }"></a>`
|
||||
|
||||
In typescript: `this.router.navigate([], { state: { focusAfterNav: false }})`
|
||||
|
||||
Example of where you might want to manually opt out:
|
||||
|
||||
- Tab component causes a route navigation, and the focus will be handled by the tab component itself
|
||||
|
||||
### Autofocus directive
|
||||
|
||||
Consumers can use the autofocus directive on an applicable interactive element. The autofocus
|
||||
directive will take precedence over the router focus management system. See the
|
||||
[Autofocus Directive docs](?path=/docs/component-library-form-autofocus-directive--docs).
|
||||
|
||||
## Browser
|
||||
|
||||
Not implemented yet.
|
||||
|
||||
## Desktop
|
||||
|
||||
Not implemented yet.
|
||||
323
libs/components/src/a11y/router-focus-manager.service.spec.ts
Normal file
323
libs/components/src/a11y/router-focus-manager.service.spec.ts
Normal file
@@ -0,0 +1,323 @@
|
||||
import {
|
||||
computed,
|
||||
DestroyRef,
|
||||
EventEmitter,
|
||||
Injectable,
|
||||
NgZone,
|
||||
Signal,
|
||||
signal,
|
||||
} from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { Event, Navigation, NavigationEnd, Router } from "@angular/router";
|
||||
import { mock } from "jest-mock-extended";
|
||||
import { BehaviorSubject, Subject } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { RouterFocusManagerService } from "./router-focus-manager.service";
|
||||
|
||||
describe("RouterFocusManagerService", () => {
|
||||
@Injectable()
|
||||
class MockNgZone extends NgZone {
|
||||
onStable: EventEmitter<any> = new EventEmitter(false);
|
||||
constructor() {
|
||||
super({ enableLongStackTrace: false });
|
||||
}
|
||||
run(fn: any): any {
|
||||
return fn();
|
||||
}
|
||||
runOutsideAngular(fn: any): any {
|
||||
return fn();
|
||||
}
|
||||
simulateZoneExit(): void {
|
||||
this.onStable.emit(null);
|
||||
}
|
||||
|
||||
isStable: boolean = true;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
class MockRouter extends Router {
|
||||
readonly currentNavigationExtras = signal({});
|
||||
|
||||
readonly currentNavigation: Signal<Navigation> = computed(() => ({
|
||||
...mock<Navigation>(),
|
||||
extras: this.currentNavigationExtras(),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line rxjs/no-exposed-subjects
|
||||
readonly routerEventsSubject = new Subject<Event>();
|
||||
|
||||
override get events() {
|
||||
return this.routerEventsSubject.asObservable();
|
||||
}
|
||||
}
|
||||
|
||||
let service: RouterFocusManagerService;
|
||||
let featureFlagSubject: BehaviorSubject<boolean>;
|
||||
let mockRouter: MockRouter;
|
||||
let mockConfigService: Partial<ConfigService>;
|
||||
let mockNgZoneRef: MockNgZone;
|
||||
|
||||
let querySelectorSpy: jest.SpyInstance;
|
||||
let consoleWarnSpy: jest.SpyInstance;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock ConfigService
|
||||
featureFlagSubject = new BehaviorSubject<boolean>(true);
|
||||
mockConfigService = {
|
||||
getFeatureFlag$: jest.fn((flag: FeatureFlag) => {
|
||||
if (flag === FeatureFlag.RouterFocusManagement) {
|
||||
return featureFlagSubject.asObservable();
|
||||
}
|
||||
return new BehaviorSubject(false).asObservable();
|
||||
}) as ConfigService["getFeatureFlag$"],
|
||||
};
|
||||
|
||||
// Spy on document.querySelector and console.warn
|
||||
querySelectorSpy = jest.spyOn(document, "querySelector");
|
||||
consoleWarnSpy = jest.spyOn(console, "warn").mockImplementation();
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
RouterFocusManagerService,
|
||||
{ provide: Router, useClass: MockRouter },
|
||||
{ provide: ConfigService, useValue: mockConfigService },
|
||||
{ provide: NgZone, useClass: MockNgZone },
|
||||
{ provide: DestroyRef, useValue: { onDestroy: jest.fn() } },
|
||||
],
|
||||
});
|
||||
|
||||
service = TestBed.inject(RouterFocusManagerService);
|
||||
mockNgZoneRef = TestBed.inject(NgZone) as MockNgZone;
|
||||
mockRouter = TestBed.inject(Router) as MockRouter;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
querySelectorSpy.mockRestore();
|
||||
consoleWarnSpy.mockRestore();
|
||||
|
||||
mockNgZoneRef.isStable = true;
|
||||
TestBed.resetTestingModule();
|
||||
});
|
||||
|
||||
describe("default behavior", () => {
|
||||
it("should focus main element after navigation", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation (should trigger focus)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).toHaveBeenCalledWith("main");
|
||||
expect(mainElement.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom selector", () => {
|
||||
it("should focus custom element when focusAfterNav selector is provided", () => {
|
||||
const customElement = document.createElement("button");
|
||||
customElement.id = "custom-btn";
|
||||
customElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(customElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation with custom selector
|
||||
mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: "#custom-btn" } });
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).toHaveBeenCalledWith("#custom-btn");
|
||||
expect(customElement.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("opt-out", () => {
|
||||
it("should not focus when focusAfterNav is false", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation with opt-out
|
||||
mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: false } });
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).not.toHaveBeenCalled();
|
||||
expect(mainElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("element not found", () => {
|
||||
it("should log warning when custom selector does not match any element", () => {
|
||||
querySelectorSpy.mockReturnValue(null);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation with non-existent selector
|
||||
mockRouter.currentNavigationExtras.set({ state: { focusAfterNav: "#non-existent" } });
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).toHaveBeenCalledWith("#non-existent");
|
||||
expect(consoleWarnSpy).toHaveBeenCalledWith(
|
||||
'RouterFocusManager: Could not find element with selector "#non-existent"',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// Remove describe block when FeatureFlag.RouterFocusManagement is removed
|
||||
describe("feature flag", () => {
|
||||
it("should not activate when RouterFocusManagement flag is disabled", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Disable feature flag
|
||||
featureFlagSubject.next(false);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation with flag disabled
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).not.toHaveBeenCalled();
|
||||
expect(mainElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should activate when RouterFocusManagement flag is enabled", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Ensure feature flag is enabled
|
||||
featureFlagSubject.next(true);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation with flag enabled
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(querySelectorSpy).toHaveBeenCalledWith("main");
|
||||
expect(mainElement.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("first navigation skip", () => {
|
||||
it("should not trigger focus management on first navigation after page load", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
expect(querySelectorSpy).not.toHaveBeenCalled();
|
||||
expect(mainElement.focus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should trigger focus management on second and subsequent navigations", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation (should trigger focus)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/second", "/second"));
|
||||
|
||||
expect(querySelectorSpy).toHaveBeenCalledWith("main");
|
||||
expect(mainElement.focus).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Emit third navigation (should also trigger focus)
|
||||
mainElement.focus = jest.fn(); // Reset mock
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(3, "/third", "/third"));
|
||||
|
||||
expect(mainElement.focus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("NgZone stability", () => {
|
||||
it("should focus immediately when zone is stable", () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
expect(mainElement.focus).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should wait for zone stability before focusing when zone is not stable", async () => {
|
||||
const mainElement = document.createElement("main");
|
||||
mainElement.focus = jest.fn();
|
||||
querySelectorSpy.mockReturnValue(mainElement);
|
||||
|
||||
// Set zone as not stable
|
||||
mockNgZoneRef.isStable = false;
|
||||
|
||||
// Subscribe to start the service
|
||||
service.start$.subscribe();
|
||||
|
||||
// Emit first navigation (should be skipped)
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(1, "/first", "/first"));
|
||||
|
||||
// Emit second navigation
|
||||
mockRouter.routerEventsSubject.next(new NavigationEnd(2, "/test", "/test"));
|
||||
|
||||
// Focus should not happen yet
|
||||
expect(mainElement.focus).not.toHaveBeenCalled();
|
||||
|
||||
// Emit zone stability
|
||||
mockNgZoneRef.onStable.emit(true);
|
||||
|
||||
// flush promises
|
||||
await Promise.resolve();
|
||||
|
||||
// Now focus should have happened
|
||||
expect(mainElement.focus).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,40 +1,23 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { inject, Injectable, NgZone } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { skip, filter, combineLatestWith, tap } from "rxjs";
|
||||
import { skip, filter, combineLatestWith, tap, map, firstValueFrom } from "rxjs";
|
||||
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
|
||||
import { queryForAutofocusDescendents } from "../input";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class RouterFocusManagerService {
|
||||
private router = inject(Router);
|
||||
private ngZone = inject(NgZone);
|
||||
|
||||
private configService = inject(ConfigService);
|
||||
|
||||
/**
|
||||
* Handles SPA route focus management. SPA apps don't automatically notify screenreader
|
||||
* users that navigation has occured or move the user's focus to the content they are
|
||||
* navigating to, so we need to do it.
|
||||
*
|
||||
* By default, we focus the `main` after an internal route navigation.
|
||||
*
|
||||
* Consumers can opt out of the passing the following to the `state` input. Using `state`
|
||||
* allows us to access the value between browser back/forward arrows.
|
||||
* In template: `<a [routerLink]="route()" [state]="{ focusMainAfterNav: false }"></a>`
|
||||
* In typescript: `this.router.navigate([], { state: { focusMainAfterNav: false }})`
|
||||
*
|
||||
* Or, consumers can use the autofocus directive on an applicable interactive element.
|
||||
* The autofocus directive will take precedence over this route focus pipeline.
|
||||
*
|
||||
* Example of where you might want to manually opt out:
|
||||
* - Tab component causes a route navigation, but the tab content should be focused,
|
||||
* not the whole `main`
|
||||
*
|
||||
* Note that router events that cause a fully new page to load (like switching between
|
||||
* products) will not follow this pipeline. Instead, those will automatically bring
|
||||
* focus to the top of the html document as if it were a full page load. So those links
|
||||
* do not need to manually opt out of this pipeline.
|
||||
* See associated router-focus-manager.mdx page for documentation on what this pipeline does and
|
||||
* how to customize focus behavior.
|
||||
*/
|
||||
start$ = this.router.events.pipe(
|
||||
takeUntilDestroyed(),
|
||||
@@ -46,19 +29,47 @@ export class RouterFocusManagerService {
|
||||
skip(1),
|
||||
combineLatestWith(this.configService.getFeatureFlag$(FeatureFlag.RouterFocusManagement)),
|
||||
filter(([_navEvent, flagEnabled]) => flagEnabled),
|
||||
filter(() => {
|
||||
map(() => {
|
||||
const currentNavExtras = this.router.currentNavigation()?.extras;
|
||||
|
||||
const focusMainAfterNav: boolean | undefined = currentNavExtras?.state?.focusMainAfterNav;
|
||||
const focusAfterNav: boolean | string | undefined = currentNavExtras?.state?.focusAfterNav;
|
||||
|
||||
return focusMainAfterNav !== false;
|
||||
return focusAfterNav;
|
||||
}),
|
||||
tap(() => {
|
||||
const mainEl = document.querySelector<HTMLElement>("main");
|
||||
filter((focusAfterNav) => {
|
||||
return focusAfterNav !== false;
|
||||
}),
|
||||
tap(async (focusAfterNav) => {
|
||||
let elSelector: string = "main";
|
||||
|
||||
if (mainEl) {
|
||||
mainEl.focus();
|
||||
if (typeof focusAfterNav === "string" && focusAfterNav.length > 0) {
|
||||
elSelector = focusAfterNav;
|
||||
}
|
||||
|
||||
if (this.ngZone.isStable) {
|
||||
this.focusTargetEl(elSelector);
|
||||
} else {
|
||||
await firstValueFrom(this.ngZone.onStable);
|
||||
|
||||
this.focusTargetEl(elSelector);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
private focusTargetEl(elSelector: string) {
|
||||
const targetEl = document.querySelector<HTMLElement>(elSelector);
|
||||
const mainEl = document.querySelector<HTMLElement>("main");
|
||||
|
||||
const pageHasAutofocusEl = mainEl && queryForAutofocusDescendents(mainEl).length > 0;
|
||||
|
||||
if (pageHasAutofocusEl) {
|
||||
// do nothing because autofocus will handle the focus
|
||||
return;
|
||||
} else if (targetEl) {
|
||||
targetEl.focus();
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(`RouterFocusManager: Could not find element with selector "${elSelector}"`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { importProvidersFrom, Component } from "@angular/core";
|
||||
import { importProvidersFrom, Component, ChangeDetectionStrategy, signal } from "@angular/core";
|
||||
import { RouterModule, Routes } from "@angular/router";
|
||||
import {
|
||||
Meta,
|
||||
@@ -31,7 +31,6 @@ export default {
|
||||
} as Meta;
|
||||
|
||||
const decorators = (options: {
|
||||
components: any[];
|
||||
routes: Routes;
|
||||
applicationVersion?: string;
|
||||
clientType?: ClientType;
|
||||
@@ -56,7 +55,6 @@ const decorators = (options: {
|
||||
},
|
||||
),
|
||||
moduleMetadata({
|
||||
declarations: options.components,
|
||||
imports: [RouterModule, ButtonModule],
|
||||
providers: [
|
||||
{
|
||||
@@ -103,39 +101,31 @@ type Story = StoryObj<AnonLayoutWrapperComponent>;
|
||||
|
||||
// Default Example
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-default-primary-outlet-example-component",
|
||||
template: "<p>Primary Outlet Example: <br> your primary component goes here</p>",
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DefaultPrimaryOutletExampleComponent {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-default-secondary-outlet-example-component",
|
||||
template: "<p>Secondary Outlet Example: <br> your secondary component goes here</p>",
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DefaultSecondaryOutletExampleComponent {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-default-env-selector-outlet-example-component",
|
||||
template: "<p>Env Selector Outlet Example: <br> your env selector component goes here</p>",
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DefaultEnvSelectorOutletExampleComponent {}
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-header-actions-outlet-example-component",
|
||||
template: "<p>Header Actions Outlet Example: <br> your header actions component goes here</p>",
|
||||
standalone: false,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DefaultHeaderActionsOutletExampleComponent {}
|
||||
|
||||
@@ -145,11 +135,6 @@ export const DefaultContentExample: Story = {
|
||||
template: "<router-outlet></router-outlet>",
|
||||
}),
|
||||
decorators: decorators({
|
||||
components: [
|
||||
DefaultPrimaryOutletExampleComponent,
|
||||
DefaultSecondaryOutletExampleComponent,
|
||||
DefaultEnvSelectorOutletExampleComponent,
|
||||
],
|
||||
routes: [
|
||||
{
|
||||
path: "**",
|
||||
@@ -212,28 +197,27 @@ const changedData: AnonLayoutWrapperData = {
|
||||
pageIcon: RegistrationCheckEmailIcon,
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "bit-dynamic-content-example-component",
|
||||
template: `
|
||||
<button type="button" bitButton buttonType="primary" (click)="toggleData()">Toggle Data</button>
|
||||
`,
|
||||
standalone: false,
|
||||
imports: [ButtonModule],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
export class DynamicContentExampleComponent {
|
||||
initialData = true;
|
||||
private readonly initialData = signal(true);
|
||||
|
||||
constructor(private anonLayoutWrapperDataService: AnonLayoutWrapperDataService) {}
|
||||
|
||||
toggleData() {
|
||||
if (this.initialData) {
|
||||
if (this.initialData()) {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(changedData);
|
||||
} else {
|
||||
this.anonLayoutWrapperDataService.setAnonLayoutWrapperData(initialData);
|
||||
}
|
||||
|
||||
this.initialData = !this.initialData;
|
||||
this.initialData.update((v) => !v);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +227,6 @@ export const DynamicContentExample: Story = {
|
||||
template: "<router-outlet></router-outlet>",
|
||||
}),
|
||||
decorators: decorators({
|
||||
components: [DynamicContentExampleComponent],
|
||||
routes: [
|
||||
{
|
||||
path: "**",
|
||||
|
||||
@@ -38,7 +38,7 @@ export class BerryComponent {
|
||||
});
|
||||
|
||||
protected readonly textColor = computed(() => {
|
||||
return this.variant() === "contrast" ? "tw-text-fg-dark" : "tw-text-fg-white";
|
||||
return this.variant() === "contrast" ? "tw-text-fg-heading" : "tw-text-fg-contrast";
|
||||
});
|
||||
|
||||
protected readonly padding = computed(() => {
|
||||
@@ -67,7 +67,7 @@ export class BerryComponent {
|
||||
warning: "tw-bg-bg-warning",
|
||||
danger: "tw-bg-bg-danger",
|
||||
accentPrimary: "tw-bg-fg-accent-primary-strong",
|
||||
contrast: "tw-bg-bg-white",
|
||||
contrast: "tw-bg-bg-primary",
|
||||
};
|
||||
|
||||
return [
|
||||
|
||||
@@ -75,7 +75,9 @@ export const statusType: Story = {
|
||||
<bit-berry [type]="'status'" variant="warning"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="danger"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="accentPrimary"></bit-berry>
|
||||
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
|
||||
<div class="tw-p-2 tw-bg-bg-contrast">
|
||||
<bit-berry [type]="'status'" variant="contrast"></bit-berry>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
@@ -153,8 +155,8 @@ export const AllVariants: Story = {
|
||||
<bit-berry variant="accentPrimary" [value]="5000"></bit-berry>
|
||||
</div>
|
||||
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-dark">
|
||||
<span class="tw-w-20 tw-text-fg-white">Contrast:</span>
|
||||
<div class="tw-flex tw-items-center tw-gap-4 tw-bg-bg-contrast">
|
||||
<span class="tw-w-20 tw-text-fg-contrast">Contrast:</span>
|
||||
<bit-berry type="status" variant="contrast"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="5"></bit-berry>
|
||||
<bit-berry variant="contrast" [value]="50"></bit-berry>
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import { NgClass, NgTemplateOutlet } from "@angular/common";
|
||||
import {
|
||||
input,
|
||||
HostBinding,
|
||||
@@ -72,7 +71,7 @@ const buttonStyles: Record<ButtonType, string[]> = {
|
||||
selector: "button[bitButton], a[bitButton]",
|
||||
templateUrl: "button.component.html",
|
||||
providers: [{ provide: ButtonLikeAbstraction, useExisting: ButtonComponent }],
|
||||
imports: [NgClass, NgTemplateOutlet, SpinnerComponent],
|
||||
imports: [SpinnerComponent],
|
||||
hostDirectives: [AriaDisableDirective],
|
||||
})
|
||||
export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
@@ -124,14 +123,31 @@ export class ButtonComponent implements ButtonLikeAbstraction {
|
||||
return this.showLoadingStyle() || (this.disabledAttr() && this.loading() === false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Style variant of the button.
|
||||
*/
|
||||
readonly buttonType = input<ButtonType>("secondary");
|
||||
|
||||
/**
|
||||
* Bitwarden icon displayed **before** the button label.
|
||||
* Spacing between the icon and label is handled automatically.
|
||||
*/
|
||||
readonly startIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Bitwarden icon (`bwi-*`) displayed **after** the button label.
|
||||
* Spacing between the label and icon is handled automatically.
|
||||
*/
|
||||
readonly endIcon = input<BitwardenIcon | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* Size variant of the button.
|
||||
*/
|
||||
readonly size = input<ButtonSize>("default");
|
||||
|
||||
/**
|
||||
* When `true`, the button expands to fill the full width of its container.
|
||||
*/
|
||||
readonly block = input(false, { transform: booleanAttribute });
|
||||
|
||||
readonly loading = model<boolean>(false);
|
||||
|
||||
@@ -80,21 +80,20 @@ where the width is fixed and the text wraps to 2 lines if exceeding the button
|
||||
|
||||
## With Icon
|
||||
|
||||
To ensure consistent icon spacing, the icon should have .5rem spacing on left or right(depending on
|
||||
placement).
|
||||
Use the `startIcon` and `endIcon` inputs to add a Bitwarden icon (`bwi-*`) before or after the
|
||||
button label. Do not use a `<bit-icon>` component inside the button as this may not have the correct
|
||||
styling and spacing.
|
||||
|
||||
> NOTE: Use logical css properties to ensure LTR/RTL support.
|
||||
|
||||
**If icon is placed before button label**
|
||||
### Icon before the label
|
||||
|
||||
```html
|
||||
<i class="bwi bwi-plus tw-me-2"></i>
|
||||
<button bitButton startIcon="bwi-plus">Add item</button>
|
||||
```
|
||||
|
||||
**If icon is placed after button label**
|
||||
### Icon after the label
|
||||
|
||||
```html
|
||||
<i class="bwi bwi-plus tw-ms-2"></i>
|
||||
<button bitButton endIcon="bwi-angle-right">Next</button>
|
||||
```
|
||||
|
||||
<Canvas of={stories.WithIcon} />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChangeDetectionStrategy, Component, signal } from "@angular/core";
|
||||
import { ChangeDetectionStrategy, Component, signal, computed } from "@angular/core";
|
||||
import { ComponentFixture, TestBed } from "@angular/core/testing";
|
||||
import { FormControl } from "@angular/forms";
|
||||
import { By } from "@angular/platform-browser";
|
||||
@@ -502,3 +502,157 @@ class TestAppComponent {
|
||||
readonly disabled = signal(false);
|
||||
readonly fullWidth = signal(false);
|
||||
}
|
||||
|
||||
describe("ChipSelectComponentWithDynamicOptions", () => {
|
||||
let component: ChipSelectComponent<string>;
|
||||
let fixture: ComponentFixture<TestAppWithDynamicOptionsComponent>;
|
||||
|
||||
const getChipButton = () =>
|
||||
fixture.debugElement.query(By.css("[data-fvw-target]"))?.nativeElement as HTMLButtonElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [TestAppWithDynamicOptionsComponent, NoopAnimationsModule],
|
||||
providers: [{ provide: I18nService, useValue: mockI18nService }],
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(TestAppWithDynamicOptionsComponent);
|
||||
fixture.detectChanges();
|
||||
|
||||
component = fixture.debugElement.query(By.directive(ChipSelectComponent)).componentInstance;
|
||||
|
||||
fixture.componentInstance.firstCounter.set(0);
|
||||
fixture.componentInstance.secondCounter.set(0);
|
||||
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
describe("User-Facing Behavior", () => {
|
||||
it("should update available options when they change", () => {
|
||||
const first = 5;
|
||||
const second = 10;
|
||||
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.firstCounter.set(first);
|
||||
testApp.secondCounter.set(second);
|
||||
fixture.detectChanges();
|
||||
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const menuItems = Array.from(document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"));
|
||||
expect(menuItems.some((el) => el.textContent?.includes(`Option - ${first}`))).toBe(true);
|
||||
expect(menuItems.some((el) => el.textContent?.includes(`Option - ${second}`))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Form Integration Behavior", () => {
|
||||
it("should display selected option when form control value is set", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.firstCounter.set(1);
|
||||
testApp.secondCounter.set(2);
|
||||
|
||||
component.writeValue("opt2"); // select second menu option which has dynamic label
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = getChipButton();
|
||||
expect(button.textContent?.trim()).toContain("Option - 2"); // verify that the label reflects the dynamic value
|
||||
|
||||
// change the dynamic values and verify that the menu still shows the correct labels for the options
|
||||
// it should also keep opt2 selected since it's the same value, just with an updated label
|
||||
const first = 10;
|
||||
const second = 20;
|
||||
|
||||
testApp.firstCounter.set(first);
|
||||
testApp.secondCounter.set(second);
|
||||
fixture.detectChanges();
|
||||
|
||||
// again, verify that the label reflects the dynamic value
|
||||
expect(button.textContent?.trim()).toContain(`Option - ${second}`);
|
||||
|
||||
// click the button to open the menu
|
||||
getChipButton().click();
|
||||
fixture.detectChanges();
|
||||
|
||||
// verify that the menu items also reflect the updated dynamic values
|
||||
const menuItems = Array.from(document.querySelectorAll<HTMLButtonElement>("[bitMenuItem]"));
|
||||
expect(menuItems.some((el) => el.textContent?.includes(`Option - ${first}`))).toBe(true);
|
||||
expect(menuItems.some((el) => el.textContent?.includes(`Option - ${second}`))).toBe(true);
|
||||
});
|
||||
|
||||
it("should find and display nested option when form control value is set", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.firstCounter.set(1);
|
||||
testApp.secondCounter.set(2);
|
||||
|
||||
component.writeValue("child1"); // select a child menu item
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = getChipButton();
|
||||
// verify that the label reflects the dynamic value for the child option
|
||||
expect(button.textContent?.trim()).toContain("Child - 1");
|
||||
|
||||
const first = 10;
|
||||
const second = 20;
|
||||
|
||||
testApp.firstCounter.set(first);
|
||||
testApp.secondCounter.set(second);
|
||||
fixture.detectChanges();
|
||||
|
||||
// again, verify that the label reflects the dynamic value
|
||||
expect(button.textContent?.trim()).toContain(`Child - ${first}`);
|
||||
});
|
||||
|
||||
it("should clear selection when form control value is set to null", () => {
|
||||
const testApp = fixture.componentInstance;
|
||||
testApp.firstCounter.set(1);
|
||||
testApp.secondCounter.set(2);
|
||||
|
||||
component.writeValue("opt1");
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(getChipButton().textContent).toContain("Option - 1");
|
||||
|
||||
component.writeValue(null as any);
|
||||
fixture.detectChanges();
|
||||
expect(getChipButton().textContent).toContain("Select an option");
|
||||
});
|
||||
});
|
||||
}); /* end of ChipSelectComponentWithDynamicOptions tests */
|
||||
@Component({
|
||||
selector: "test-app-with-dynamic-options",
|
||||
template: `
|
||||
<bit-chip-select
|
||||
placeholderText="Select an option"
|
||||
placeholderIcon="bwi-filter"
|
||||
[options]="options()"
|
||||
[disabled]="disabled()"
|
||||
[fullWidth]="fullWidth()"
|
||||
/>
|
||||
`,
|
||||
imports: [ChipSelectComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestAppWithDynamicOptionsComponent {
|
||||
readonly firstCounter = signal(1);
|
||||
readonly secondCounter = signal(2);
|
||||
readonly options = computed(() => {
|
||||
const first = this.firstCounter();
|
||||
const second = this.secondCounter();
|
||||
return [
|
||||
{ label: `Option - ${first}`, value: "opt1", icon: "bwi-folder" },
|
||||
{ label: `Option - ${second}`, value: "opt2" },
|
||||
{
|
||||
label: "Parent Option",
|
||||
value: "parent",
|
||||
children: [
|
||||
{ label: `Child - ${first}`, value: "child1" },
|
||||
{ label: `Child - ${second}`, value: "child2" },
|
||||
],
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
readonly disabled = signal(false);
|
||||
readonly fullWidth = signal(false);
|
||||
}
|
||||
|
||||
@@ -106,8 +106,19 @@ export class ChipSelectComponent<T = unknown> implements ControlValueAccessor {
|
||||
constructor() {
|
||||
// Initialize the root tree whenever options change
|
||||
effect(() => {
|
||||
const currentSelection = this.selectedOption;
|
||||
|
||||
// when the options change, clear the childParentMap
|
||||
this.childParentMap.clear();
|
||||
|
||||
this.initializeRootTree(this.options());
|
||||
|
||||
// when the options change, we need to change our selectedOption
|
||||
// to reflect the changed options.
|
||||
if (currentSelection?.value != null) {
|
||||
this.selectedOption = this.findOption(this.rootTree, currentSelection.value);
|
||||
}
|
||||
|
||||
// If there's a pending value, apply it now that options are available
|
||||
if (this.pendingValue !== undefined) {
|
||||
this.selectedOption = this.findOption(this.rootTree, this.pendingValue);
|
||||
|
||||
115
libs/components/src/dialog/dialog.service.spec.ts
Normal file
115
libs/components/src/dialog/dialog.service.spec.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Dialog as CdkDialog } from "@angular/cdk/dialog";
|
||||
import { ChangeDetectionStrategy, Component } from "@angular/core";
|
||||
import { TestBed } from "@angular/core/testing";
|
||||
import { provideRouter } from "@angular/router";
|
||||
import { RouterTestingHarness } from "@angular/router/testing";
|
||||
import { mock, MockProxy } from "jest-mock-extended";
|
||||
import { BehaviorSubject } from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
|
||||
import { DialogService } from "./dialog.service";
|
||||
import { DrawerService } from "./drawer.service";
|
||||
|
||||
@Component({
|
||||
selector: "test-drawer",
|
||||
template: "",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class TestDrawerComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "test-initial-route",
|
||||
template: "<h1>Initial Route</h1>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class InitialRouteComponent {}
|
||||
|
||||
@Component({
|
||||
selector: "test-other-route",
|
||||
template: "<h1>Other Route</h1>",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
class OtherRouteComponent {}
|
||||
|
||||
describe("DialogService", () => {
|
||||
let service: DialogService;
|
||||
let drawerService: MockProxy<DrawerService>;
|
||||
let cdkDialog: MockProxy<CdkDialog>;
|
||||
let routerHarness: RouterTestingHarness;
|
||||
let authStatus$: BehaviorSubject<AuthenticationStatus>;
|
||||
|
||||
beforeEach(async () => {
|
||||
drawerService = mock<DrawerService>();
|
||||
cdkDialog = mock<CdkDialog>();
|
||||
authStatus$ = new BehaviorSubject<AuthenticationStatus>(AuthenticationStatus.Unlocked);
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
DialogService,
|
||||
{ provide: DrawerService, useValue: drawerService },
|
||||
{ provide: CdkDialog, useValue: cdkDialog },
|
||||
{
|
||||
provide: AuthService,
|
||||
useValue: {
|
||||
getAuthStatus: () => authStatus$,
|
||||
},
|
||||
},
|
||||
provideRouter([
|
||||
{ path: "", component: InitialRouteComponent },
|
||||
{ path: "other-route", component: OtherRouteComponent },
|
||||
{ path: "another-route", component: OtherRouteComponent },
|
||||
]),
|
||||
],
|
||||
});
|
||||
|
||||
routerHarness = await RouterTestingHarness.create();
|
||||
// Navigate to the initial route to set up the router state
|
||||
await routerHarness.navigateByUrl("/");
|
||||
|
||||
service = TestBed.inject(DialogService);
|
||||
});
|
||||
|
||||
describe("close drawer on navigation", () => {
|
||||
it("closes the drawer when navigating to a different route with closeOnNavigation enabled", async () => {
|
||||
service.openDrawer(TestDrawerComponent, { closeOnNavigation: true });
|
||||
|
||||
await routerHarness.navigateByUrl("/other-route");
|
||||
|
||||
expect(drawerService.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close the drawer when navigating if closeOnNavigation is disabled", async () => {
|
||||
service.openDrawer(TestDrawerComponent, { closeOnNavigation: false });
|
||||
|
||||
await routerHarness.navigateByUrl("/other-route");
|
||||
|
||||
expect(drawerService.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close the drawer when only query params change", async () => {
|
||||
service.openDrawer(TestDrawerComponent, { closeOnNavigation: true });
|
||||
|
||||
await routerHarness.navigateByUrl("/?foo=bar");
|
||||
|
||||
expect(drawerService.close).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("closes the drawer when the path changes but query params remain", async () => {
|
||||
service.openDrawer(TestDrawerComponent, { closeOnNavigation: true });
|
||||
|
||||
await routerHarness.navigateByUrl("/other-route?foo=bar");
|
||||
|
||||
expect(drawerService.close).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not close the drawer by default when closeOnNavigation is not specified", async () => {
|
||||
service.openDrawer(TestDrawerComponent);
|
||||
|
||||
await routerHarness.navigateByUrl("/other-route");
|
||||
|
||||
expect(drawerService.close).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -10,11 +10,20 @@ import { ComponentPortal, Portal } from "@angular/cdk/portal";
|
||||
import { Injectable, Injector, TemplateRef, inject } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { NavigationEnd, Router } from "@angular/router";
|
||||
import { filter, firstValueFrom, map, Observable, Subject, switchMap, take } from "rxjs";
|
||||
import {
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
firstValueFrom,
|
||||
map,
|
||||
Observable,
|
||||
startWith,
|
||||
Subject,
|
||||
switchMap,
|
||||
take,
|
||||
} from "rxjs";
|
||||
|
||||
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
|
||||
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
|
||||
import { isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
|
||||
|
||||
@@ -62,7 +71,14 @@ export abstract class DialogRef<R = unknown, C = unknown> implements Pick<
|
||||
|
||||
export type DialogConfig<D = unknown, R = unknown> = Pick<
|
||||
CdkDialogConfig<D, R>,
|
||||
"data" | "disableClose" | "ariaModal" | "positionStrategy" | "height" | "width" | "restoreFocus"
|
||||
| "data"
|
||||
| "disableClose"
|
||||
| "ariaModal"
|
||||
| "positionStrategy"
|
||||
| "height"
|
||||
| "width"
|
||||
| "restoreFocus"
|
||||
| "closeOnNavigation"
|
||||
>;
|
||||
|
||||
/**
|
||||
@@ -137,7 +153,11 @@ class DrawerDialogRef<R = unknown, C = unknown> implements DialogRef<R, C> {
|
||||
/** The portal containing the drawer */
|
||||
portal?: Portal<unknown>;
|
||||
|
||||
constructor(private drawerService: DrawerService) {}
|
||||
constructor(
|
||||
private drawerService: DrawerService,
|
||||
/** Whether to close this drawer when navigating to a different route */
|
||||
readonly closeOnNavigation = false,
|
||||
) {}
|
||||
|
||||
close(result?: R, _options?: DialogCloseOptions): void {
|
||||
if (this.disableClose) {
|
||||
@@ -188,9 +208,8 @@ export class DialogService {
|
||||
private dialog = inject(CdkDialog);
|
||||
private drawerService = inject(DrawerService);
|
||||
private injector = inject(Injector);
|
||||
private router = inject(Router, { optional: true });
|
||||
private router = inject(Router);
|
||||
private authService = inject(AuthService, { optional: true });
|
||||
private i18nService = inject(I18nService);
|
||||
|
||||
private backDropClasses = ["tw-fixed", "tw-bg-black", "tw-bg-opacity-30", "tw-inset-0"];
|
||||
private defaultScrollStrategy = new CustomBlockScrollStrategy();
|
||||
@@ -212,6 +231,24 @@ export class DialogService {
|
||||
)
|
||||
.subscribe(() => this.closeAll());
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the active drawer on route navigation if configured.
|
||||
* Note: CDK dialogs have their own `closeOnNavigation` config option,
|
||||
* but drawers use a custom implementation that requires manual cleanup.
|
||||
*/
|
||||
if (this.router) {
|
||||
this.router.events
|
||||
.pipe(
|
||||
filter((event): event is NavigationEnd => event instanceof NavigationEnd),
|
||||
map((event) => event.urlAfterRedirects.split("?")[0]),
|
||||
startWith(this.router.url.split("?")[0]),
|
||||
distinctUntilChanged(),
|
||||
filter(() => this.activeDrawer?.closeOnNavigation === true),
|
||||
takeUntilDestroyed(),
|
||||
)
|
||||
.subscribe(() => this.closeDrawer());
|
||||
}
|
||||
}
|
||||
|
||||
open<R = unknown, D = unknown, C = unknown>(
|
||||
@@ -237,6 +274,7 @@ export class DialogService {
|
||||
backdropClass: this.backDropClasses,
|
||||
scrollStrategy: this.defaultScrollStrategy,
|
||||
positionStrategy: config?.positionStrategy ?? new ResponsivePositionStrategy(),
|
||||
closeOnNavigation: config?.closeOnNavigation,
|
||||
injector,
|
||||
...config,
|
||||
};
|
||||
@@ -260,7 +298,7 @@ export class DialogService {
|
||||
* This is also circular. When creating the DrawerDialogRef, we do not yet have a portal instance to provide to the injector.
|
||||
* Similar to `this.open`, we get around this with mutability.
|
||||
*/
|
||||
this.activeDrawer = new DrawerDialogRef(this.drawerService);
|
||||
this.activeDrawer = new DrawerDialogRef(this.drawerService, config?.closeOnNavigation ?? false);
|
||||
const portal = new ComponentPortal(
|
||||
component,
|
||||
null,
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
@let isDrawer = dialogRef?.isDrawer;
|
||||
<!-- Storybook/Angular bugs out without this nullcheck. See the Access Selector Dialog story when removed. -->
|
||||
@let widthClass = width() ?? "";
|
||||
<section
|
||||
class="tw-flex tw-w-full tw-flex-col tw-self-center tw-overflow-hidden tw-border tw-border-solid tw-border-secondary-100 tw-bg-background tw-text-main"
|
||||
[ngClass]="[
|
||||
width(),
|
||||
widthClass,
|
||||
isDrawer ? 'tw-h-full tw-border-t-0' : 'tw-rounded-t-xl md:tw-rounded-xl tw-shadow-lg',
|
||||
]"
|
||||
cdkTrapFocus
|
||||
|
||||
@@ -3,6 +3,7 @@ import { CdkScrollable } from "@angular/cdk/scrolling";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import {
|
||||
Component,
|
||||
effect,
|
||||
inject,
|
||||
viewChild,
|
||||
input,
|
||||
@@ -20,6 +21,8 @@ import { combineLatest, firstValueFrom, switchMap } from "rxjs";
|
||||
import { I18nPipe } from "@bitwarden/ui-common";
|
||||
|
||||
import { BitIconButtonComponent } from "../../icon-button/icon-button.component";
|
||||
import { queryForAutofocusDescendents } from "../../input";
|
||||
import { getRootFontSizePx } from "../../shared";
|
||||
import { SpinnerComponent } from "../../spinner";
|
||||
import { TypographyDirective } from "../../typography/typography.directive";
|
||||
import { hasScrollableContent$ } from "../../utils/";
|
||||
@@ -27,6 +30,7 @@ import { hasScrolledFrom } from "../../utils/has-scrolled-from";
|
||||
import { DialogRef } from "../dialog.service";
|
||||
import { DialogCloseDirective } from "../directives/dialog-close.directive";
|
||||
import { DialogTitleContainerDirective } from "../directives/dialog-title-container.directive";
|
||||
import { DrawerService } from "../drawer.service";
|
||||
|
||||
type DialogSize = "small" | "default" | "large";
|
||||
|
||||
@@ -42,6 +46,13 @@ const drawerSizeToWidth = {
|
||||
large: "md:tw-max-w-2xl",
|
||||
} as const;
|
||||
|
||||
/** Width in rem for each drawer size, used to declare push-mode column widths. */
|
||||
export const drawerSizeToWidthRem: Record<string, number> = {
|
||||
small: 24,
|
||||
default: 32,
|
||||
large: 42,
|
||||
};
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -67,7 +78,19 @@ const drawerSizeToWidth = {
|
||||
export class DialogComponent implements AfterViewInit {
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly ngZone = inject(NgZone);
|
||||
private readonly el = inject(ElementRef);
|
||||
private readonly el = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly drawerService = inject(DrawerService);
|
||||
|
||||
constructor() {
|
||||
effect(() => {
|
||||
if (!this.dialogRef?.isDrawer) {
|
||||
return;
|
||||
}
|
||||
const size = this.dialogSize();
|
||||
const rootFontSizePx = getRootFontSizePx();
|
||||
this.drawerService.declarePushWidth((drawerSizeToWidthRem[size] ?? 32) * rootFontSizePx);
|
||||
});
|
||||
}
|
||||
|
||||
private readonly dialogHeader =
|
||||
viewChild.required<ElementRef<HTMLHeadingElement>>("dialogHeader");
|
||||
@@ -121,23 +144,31 @@ export class DialogComponent implements AfterViewInit {
|
||||
|
||||
private readonly animationCompleted = signal(false);
|
||||
|
||||
/** Max width class */
|
||||
protected readonly width = computed(() => {
|
||||
const size = this.dialogSize() ?? "default";
|
||||
const isDrawer = this.dialogRef?.isDrawer;
|
||||
const size = this.dialogSize();
|
||||
|
||||
if (isDrawer) {
|
||||
return drawerSizeToWidth[size];
|
||||
if (this.dialogRef?.isDrawer) {
|
||||
return this.drawerService.isPushMode() ? drawerSizeToWidth[size] : "";
|
||||
}
|
||||
|
||||
return dialogSizeToWidth[size];
|
||||
});
|
||||
|
||||
protected readonly classes = computed(() => {
|
||||
// `tw-max-h-[90vh]` is needed to prevent dialogs from overlapping the desktop header
|
||||
const baseClasses = ["tw-flex", "tw-flex-col", "tw-w-screen"];
|
||||
const sizeClasses = this.dialogRef?.isDrawer ? ["tw-h-full"] : ["md:tw-p-4", "tw-max-h-[90vh]"];
|
||||
const isDrawer = this.dialogRef?.isDrawer;
|
||||
// Drawers use tw-w-full (100% of column) so the element fills its grid track
|
||||
// without overflowing — the column itself is capped by the grid template.
|
||||
// Regular dialogs use tw-w-screen for full-width mobile presentation.
|
||||
const widthClass = isDrawer ? "tw-w-full" : "tw-w-screen";
|
||||
const baseClasses = ["tw-flex", "tw-flex-col", widthClass];
|
||||
const sizeClasses = isDrawer
|
||||
? ["tw-h-full"]
|
||||
: [
|
||||
"md:tw-p-4",
|
||||
"tw-max-h-[90vh]", // needed to prevent dialogs from overlapping the desktop header
|
||||
];
|
||||
|
||||
const size = this.dialogSize() ?? "default";
|
||||
const size = this.dialogSize();
|
||||
const animationClasses =
|
||||
this.disableAnimations() || this.animationCompleted() || this.dialogRef?.isDrawer
|
||||
? []
|
||||
@@ -187,8 +218,7 @@ export class DialogComponent implements AfterViewInit {
|
||||
* AutofocusDirective.
|
||||
*/
|
||||
const dialogRef = this.el.nativeElement;
|
||||
// Must match selectors of AutofocusDirective
|
||||
const autofocusDescendants = dialogRef.querySelectorAll("[appAutofocus], [bitAutofocus]");
|
||||
const autofocusDescendants = queryForAutofocusDescendents(dialogRef);
|
||||
const hasAutofocusDescendants = autofocusDescendants.length > 0;
|
||||
|
||||
if (!hasAutofocusDescendants) {
|
||||
|
||||
@@ -28,7 +28,16 @@ For non-blocking, supplementary content, open dialogs as a
|
||||
### Closing Drawers on Navigation
|
||||
|
||||
When using drawers, you may want to close them automatically when the user navigates to another page
|
||||
to prevent the drawer from persisting across route changes. To implement this functionality:
|
||||
to prevent the drawer from persisting across route changes. In most cases you can achieve this by
|
||||
using the `closeOnNavigation` option when opening the drawer.
|
||||
|
||||
```ts
|
||||
this.dialogService.open(MyDialogComponent, { closeOnNavigation: true });
|
||||
```
|
||||
|
||||
In some cases you may want more control of when the drawer is closed, such as if you have nested
|
||||
routes and want the drawer to persist across some route changes but not others. In this case, you
|
||||
can manually close the drawer in `ngOnDestroy`. To do this:
|
||||
|
||||
1. Store a reference to the dialog when opening it
|
||||
2. Implement `OnDestroy` and close the dialog in `ngOnDestroy`
|
||||
@@ -52,8 +61,6 @@ export class MyComponent implements OnDestroy {
|
||||
}
|
||||
```
|
||||
|
||||
This ensures drawers are closed when the component is destroyed during navigation.
|
||||
|
||||
## Placement
|
||||
|
||||
Dialogs should be centered vertically and horizontally on screen. Dialogs height should expand to
|
||||
|
||||
@@ -3,18 +3,40 @@ import { Injectable, signal } from "@angular/core";
|
||||
|
||||
@Injectable({ providedIn: "root" })
|
||||
export class DrawerService {
|
||||
private readonly _portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/** The portal to display */
|
||||
portal = this._portal.asReadonly();
|
||||
readonly portal = signal<Portal<unknown> | undefined>(undefined);
|
||||
|
||||
/**
|
||||
* The drawer's preferred push-mode column width in px.
|
||||
* Declared by the drawer content (e.g. bit-dialog) via declarePushWidth().
|
||||
* Zero when no drawer is active or the width has not been declared yet.
|
||||
*/
|
||||
readonly pushWidthPx = signal(0);
|
||||
|
||||
/**
|
||||
* Whether the drawer is currently in push mode (occupying its own grid column).
|
||||
* Set by LayoutComponent via ResizeObserver; read by the drawer content for display.
|
||||
*/
|
||||
readonly isPushMode = signal(false);
|
||||
|
||||
open(portal: Portal<unknown>) {
|
||||
this._portal.set(portal);
|
||||
this.portal.set(portal);
|
||||
}
|
||||
|
||||
close(portal: Portal<unknown>) {
|
||||
if (portal === this.portal()) {
|
||||
this._portal.set(undefined);
|
||||
this.portal.set(undefined);
|
||||
this.pushWidthPx.set(0);
|
||||
this.isPushMode.set(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by drawer content components (e.g. bit-dialog) to declare their natural
|
||||
* push-mode column width so LayoutComponent can make accurate push/overlay decisions
|
||||
* without measuring the DOM (which is unreliable when the column is 1fr).
|
||||
*/
|
||||
declarePushWidth(px: number) {
|
||||
this.pushWidthPx.set(px);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ import { TypographyDirective } from "../typography/typography.directive";
|
||||
templateUrl: "./header.component.html",
|
||||
imports: [TypographyDirective],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
})
|
||||
export class HeaderComponent {
|
||||
/**
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import { ChangeDetectionStrategy, Component, computed, input } from "@angular/core";
|
||||
import {
|
||||
booleanAttribute,
|
||||
ChangeDetectionStrategy,
|
||||
Component,
|
||||
computed,
|
||||
input,
|
||||
} from "@angular/core";
|
||||
|
||||
import { BitwardenIcon } from "../shared/icon";
|
||||
|
||||
@Component({
|
||||
selector: "bit-icon",
|
||||
standalone: true,
|
||||
host: {
|
||||
"[class]": "classList()",
|
||||
"[attr.aria-hidden]": "ariaLabel() ? null : true",
|
||||
@@ -24,7 +29,12 @@ export class IconComponent {
|
||||
*/
|
||||
readonly ariaLabel = input<string>();
|
||||
|
||||
protected readonly classList = computed(() => {
|
||||
return ["bwi", this.name()].join(" ");
|
||||
});
|
||||
/**
|
||||
* Whether the icon should use a fixed width
|
||||
*/
|
||||
readonly fixedWidth = input(false, { transform: booleanAttribute });
|
||||
|
||||
protected readonly classList = computed(() =>
|
||||
["bwi", this.name(), this.fixedWidth() && "bwi-fw"].filter(Boolean),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ The `bit-icon` component renders Bitwarden Web Icons (bwi) using icon font class
|
||||
## Basic Usage
|
||||
|
||||
```html
|
||||
<bit-icon name="bwi-lock"></bit-icon>
|
||||
<bit-icon name="bwi-lock" />
|
||||
```
|
||||
|
||||
## Icon Names
|
||||
@@ -29,7 +29,7 @@ By default, icons are decorative and marked with `aria-hidden="true"`. To make a
|
||||
provide an `ariaLabel`:
|
||||
|
||||
```html
|
||||
<bit-icon name="bwi-lock" [ariaLabel]="'Secure lock'"></bit-icon>
|
||||
<bit-icon name="bwi-lock" [ariaLabel]="'Secure lock'" />
|
||||
```
|
||||
|
||||
## Styling
|
||||
@@ -38,7 +38,7 @@ The component renders as an inline element. Apply standard CSS classes or styles
|
||||
appearance:
|
||||
|
||||
```html
|
||||
<bit-icon name="bwi-lock" class="tw-text-primary-500 tw-text-2xl"></bit-icon>
|
||||
<bit-icon name="bwi-lock" class="tw-text-primary-500 tw-text-2xl" />
|
||||
```
|
||||
|
||||
## Note on SVG Icons
|
||||
|
||||
@@ -54,6 +54,47 @@ export const WithAriaLabel: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const FixedWidth: Story = {
|
||||
args: {
|
||||
name: "bwi-lock",
|
||||
fixedWidth: true,
|
||||
},
|
||||
};
|
||||
|
||||
export const FixedWidthComparison: Story = {
|
||||
render: () => ({
|
||||
template: `
|
||||
<div class="tw-flex tw-flex-col tw-gap-2">
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-lock" fixedWidth />
|
||||
<span>bwi-lock (fixed width)</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-eye" fixedWidth />
|
||||
<span>bwi-eye (fixed width)</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-collection" fixedWidth />
|
||||
<span>bwi-collection (fixed width)</span>
|
||||
</div>
|
||||
<hr class="tw-my-2" />
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-lock" />
|
||||
<span>bwi-lock (default)</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-eye" />
|
||||
<span>bwi-eye (default)</span>
|
||||
</div>
|
||||
<div class="tw-flex tw-items-center tw-gap-2">
|
||||
<bit-icon name="bwi-collection" />
|
||||
<span>bwi-collection (default)</span>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const CompareWithLegacy: Story = {
|
||||
render: () => ({
|
||||
template: `<bit-icon name="bwi-lock"></bit-icon> <i class="bwi bwi-lock"></i>`,
|
||||
|
||||
@@ -13,6 +13,18 @@ import { Utils } from "@bitwarden/common/platform/misc/utils";
|
||||
|
||||
import { FocusableElement } from "../shared/focusable-element";
|
||||
|
||||
/**
|
||||
* Helper function to query for descendents of a given el that have the AutofocusDirective
|
||||
* applied to them
|
||||
*
|
||||
* @param el element that supports querySelectorAll
|
||||
* @returns querySelectorAll results
|
||||
*/
|
||||
export function queryForAutofocusDescendents(el: Document | Element) {
|
||||
// ensure selectors match the directive selectors
|
||||
return el.querySelectorAll("[appAutofocus], [bitAutofocus]");
|
||||
}
|
||||
|
||||
/**
|
||||
* Directive to focus an element.
|
||||
*
|
||||
@@ -21,9 +33,7 @@ import { FocusableElement } from "../shared/focusable-element";
|
||||
* Will focus the element once, when it becomes visible.
|
||||
*
|
||||
* If the component provides the `FocusableElement` interface, the `focus`
|
||||
* method will be called. Otherwise, the native element will be focused.
|
||||
*
|
||||
* If selector changes, `dialog.component.ts` must also be updated
|
||||
* method will be called. Otherwise, the native element will be focused. *
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[appAutofocus], [bitAutofocus]",
|
||||
|
||||
@@ -26,7 +26,6 @@ import { BaseCardComponent } from "../card";
|
||||
@Component({
|
||||
selector: "bit-landing-card",
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
standalone: true,
|
||||
imports: [BaseCardComponent],
|
||||
templateUrl: "./landing-card.component.html",
|
||||
})
|
||||
|
||||
@@ -1,45 +1,68 @@
|
||||
@let mainContentId = "main-content";
|
||||
<div class="tw-flex tw-size-full" [class.tw-bg-background-alt3]="rounded()">
|
||||
<div class="tw-flex tw-size-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-full tw-min-w-0 tw-flex-1 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
|
||||
[class.tw-rounded-tl-2xl]="rounded()"
|
||||
>
|
||||
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
<!-- overlay backdrop for side-nav -->
|
||||
<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"
|
||||
[class]="sideNavService.open() ? 'tw-bg-opacity-30 md:tw-bg-opacity-0' : 'tw-bg-opacity-0'"
|
||||
>
|
||||
@if (sideNavService.open()) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
<div
|
||||
#container
|
||||
class="tw-grid tw-relative tw-size-full tw-overflow-hidden tw-grid-rows-[minmax(0,1fr)]"
|
||||
[style.grid-template-columns]="gridTemplateColumns()"
|
||||
[class.tw-bg-background-alt3]="rounded()"
|
||||
cdkTrapFocus
|
||||
>
|
||||
<!-- Skip-to-content link -->
|
||||
<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>
|
||||
<div class="tw-absolute tw-z-50 tw-left-0 md:tw-sticky tw-top-0 tw-h-full md:tw-w-auto">
|
||||
|
||||
<!-- Col 1 -->
|
||||
<ng-content select="bit-side-nav, [slot=side-nav]">
|
||||
<ng-container #sideNavSlotFallback></ng-container>
|
||||
</ng-content>
|
||||
|
||||
<!-- Siderail width placeholder — keeps the col 1 auto track stable when the nav
|
||||
is position:fixed (overlay) and therefore out of the grid's normal flow. -->
|
||||
@if (sideNavService.isOverlay() && siderailIsPushMode()) {
|
||||
<div
|
||||
aria-hidden="true"
|
||||
class="tw-pointer-events-none tw-w-[3.75rem] tw-mx-0.5 tw-col-start-1 tw-row-start-1"
|
||||
></div>
|
||||
}
|
||||
|
||||
<!-- Main content (always col 2) -->
|
||||
<main
|
||||
#main
|
||||
[id]="mainContentId"
|
||||
tabindex="-1"
|
||||
bitScrollLayoutHost
|
||||
class="tw-col-start-2 tw-row-start-1 tw-overflow-auto tw-max-h-full tw-min-w-96 tw-bg-background tw-p-8 tw-pt-6 tw-@container"
|
||||
[class.tw-rounded-tl-2xl]="rounded()"
|
||||
>
|
||||
<!-- ^ If updating this padding, also update the padding correction in bit-banner! ^ -->
|
||||
<ng-content></ng-content>
|
||||
</main>
|
||||
|
||||
<!-- Overlay backdrop for side-nav (fixed, z-40 — below nav overlay z-50) -->
|
||||
<div
|
||||
class="tw-pointer-events-none tw-fixed tw-inset-0 tw-z-40 tw-bg-black tw-bg-opacity-0 motion-safe:tw-transition-colors"
|
||||
[class.tw-bg-opacity-30]="sideNavService.isOverlay()"
|
||||
>
|
||||
@if (sideNavService.isOverlay()) {
|
||||
<div (click)="sideNavService.toggle()" class="tw-pointer-events-auto tw-size-full"></div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Drawer (always col 3; col 3 track is auto when push, 1fr when overlay, 0px when closed) -->
|
||||
<div #drawerContainer class="tw-col-start-3 tw-row-start-1 tw-relative tw-z-30 tw-h-full">
|
||||
<ng-template [cdkPortalOutlet]="drawerPortal()"></ng-template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,16 +1,35 @@
|
||||
import { A11yModule, CdkTrapFocus } from "@angular/cdk/a11y";
|
||||
import { PortalModule } from "@angular/cdk/portal";
|
||||
import { CommonModule } from "@angular/common";
|
||||
import { booleanAttribute, Component, ElementRef, inject, input, viewChild } from "@angular/core";
|
||||
import {
|
||||
afterNextRender,
|
||||
booleanAttribute,
|
||||
Component,
|
||||
computed,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
inject,
|
||||
input,
|
||||
signal,
|
||||
viewChild,
|
||||
} from "@angular/core";
|
||||
import { RouterModule } from "@angular/router";
|
||||
|
||||
import { drawerSizeToWidthRem } from "../dialog/dialog/dialog.component";
|
||||
import { DrawerService } from "../dialog/drawer.service";
|
||||
import { LinkComponent, LinkModule } from "../link";
|
||||
import { SideNavService } from "../navigation/side-nav.service";
|
||||
import { SharedModule } from "../shared";
|
||||
import { getRootFontSizePx, SharedModule } from "../shared";
|
||||
|
||||
import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
|
||||
|
||||
/** Matches tw-min-w-96 on <main>. */
|
||||
const MAIN_MIN_WIDTH_REM = 24;
|
||||
|
||||
/** Approximate rendered width of the closed nav (siderail / icon strip).
|
||||
* Derived from tw-w-[3.75rem] + tw-mx-0.5 margins in side-nav.component.html. */
|
||||
const SIDERAIL_WIDTH_REM = 4;
|
||||
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
@@ -33,9 +52,217 @@ import { ScrollLayoutHostDirective } from "./scroll-layout.directive";
|
||||
})
|
||||
export class LayoutComponent {
|
||||
protected sideNavService = inject(SideNavService);
|
||||
protected drawerPortal = inject(DrawerService).portal;
|
||||
private readonly drawerService = inject(DrawerService);
|
||||
protected drawerPortal = this.drawerService.portal;
|
||||
|
||||
/** Rendered only when nothing is projected into the side-nav slot (ng-content fallback). */
|
||||
private readonly sideNavSlotFallback = viewChild<ElementRef>("sideNavSlotFallback");
|
||||
protected readonly hasSideNav = computed(() => this.sideNavSlotFallback() == null);
|
||||
|
||||
/**
|
||||
* True as soon as a portal is active; false when no drawer is open.
|
||||
* Derived directly from the portal signal so col 3 gets a non-zero track
|
||||
* immediately on open — without waiting for the ResizeObserver to fire.
|
||||
* This breaks the chicken-and-egg: col 3 = 0px → no resize event → drawer
|
||||
* never appears.
|
||||
*/
|
||||
private readonly drawerIsActive = computed(() => this.drawerPortal() != null);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly container = viewChild.required<ElementRef<HTMLElement>>("container");
|
||||
private readonly mainContent = viewChild.required<ElementRef<HTMLElement>>("main");
|
||||
private readonly drawerContainer = viewChild.required<ElementRef<HTMLElement>>("drawerContainer");
|
||||
|
||||
/**
|
||||
* Container width in px, updated by the ResizeObserver on every layout change.
|
||||
* Exposed as a signal so gridTemplateColumns can reactively compute push vs
|
||||
* overlay for the drawer without waiting for a ResizeObserver tick.
|
||||
*/
|
||||
private readonly containerWidthPx = signal(0);
|
||||
|
||||
/**
|
||||
* Whether the siderail (closed-nav icon strip) fits in its own column.
|
||||
* Has a lower threshold than full-nav isPushMode because the siderail is
|
||||
* much narrower — it should remain visible on intermediate viewport widths.
|
||||
*/
|
||||
protected readonly siderailIsPushMode = signal(false);
|
||||
|
||||
/**
|
||||
* The CSS grid-template-columns value for the three-panel layout.
|
||||
*
|
||||
* Column 1 (nav): navWidthRem when nav is push+open
|
||||
* auto when nav is push+closed (icon strip) OR
|
||||
* when only the siderail fits; a dummy placeholder
|
||||
* div keeps col 1 stable when the nav is fixed (overlay)
|
||||
* 0px when even the siderail doesn't fit
|
||||
* Column 2 (main): minmax(mainMinWidthPx, 1fr) normally — the minmax base reserves
|
||||
* space for main so CSS grid can shrink col 3 without JS arithmetic;
|
||||
* 0px when drawer is in overlay mode (drawer takes the full row)
|
||||
* Column 3 (drawer): auto when push (CSS shrinks naturally from declared max down to
|
||||
* drawerMinPushWidthPx before JS switches to overlay);
|
||||
* 1fr when overlay (takes over main's space); 0px when no drawer
|
||||
*/
|
||||
protected readonly gridTemplateColumns = computed(() => {
|
||||
const navOpen = this.sideNavService.open();
|
||||
const navPush = this.sideNavService.isPushMode();
|
||||
const siderailPush = this.siderailIsPushMode();
|
||||
|
||||
// --- Drawer push/shrink/overlay ---
|
||||
const drawerActive = this.drawerIsActive();
|
||||
const declaredDrawerWidth = this.drawerService.pushWidthPx();
|
||||
const containerWidth = this.containerWidthPx();
|
||||
const rootFontSizePx = getRootFontSizePx();
|
||||
const siderailWidthPx = SIDERAIL_WIDTH_REM * rootFontSizePx;
|
||||
const drawerMinWidthPx = drawerSizeToWidthRem.small * rootFontSizePx;
|
||||
const mainMinWidthPx = MAIN_MIN_WIDTH_REM * rootFontSizePx;
|
||||
|
||||
// Push vs overlay: switch to overlay only when the minimum push width won't fit.
|
||||
// The shrink zone between the declared max-width and the minimum is handled
|
||||
// entirely by CSS grid: col2 uses minmax(mainMinWidthPx, 1fr) so its base
|
||||
// size reserves space for main before col3 auto grows. When the container
|
||||
// shrinks, col3 naturally receives less free space and narrows without any JS
|
||||
// pixel arithmetic.
|
||||
//
|
||||
// dialog.component declares its push width via an effect() that runs during
|
||||
// Angular's CD — before the ResizeObserver fires and before the browser paints.
|
||||
// Falls back to the ResizeObserver-driven signal when not yet declared.
|
||||
let drawerPush: boolean;
|
||||
if (!drawerActive) {
|
||||
drawerPush = false;
|
||||
} else if (declaredDrawerWidth > 0 && containerWidth > 0) {
|
||||
drawerPush = containerWidth - siderailWidthPx - drawerMinWidthPx >= mainMinWidthPx;
|
||||
} else {
|
||||
drawerPush = this.drawerService.isPushMode();
|
||||
}
|
||||
|
||||
// --- Col 1 (nav / siderail) ---
|
||||
// When the nav enters overlay mode (position:fixed) it leaves the grid's normal
|
||||
// flow. A dummy placeholder div in the template keeps the col 1 auto track
|
||||
// stable without needing an explicit px value here.
|
||||
let col1: string;
|
||||
if (!this.hasSideNav()) {
|
||||
col1 = "0px"; // no side nav projected — collapse the column entirely
|
||||
} else if (navOpen && navPush) {
|
||||
col1 = `${this.sideNavService.widthRem()}rem`; // full nav, push+open
|
||||
} else if (navPush || siderailPush) {
|
||||
col1 = "auto"; // siderail in flow, size naturally
|
||||
} else {
|
||||
col1 = "0px"; // viewport too narrow even for siderail
|
||||
}
|
||||
|
||||
// col3: minmax(0px, declaredMax) instead of "auto" so the track is sized by its
|
||||
// explicit bounds rather than by the item's content-based size. This lets CSS
|
||||
// grid shrink the drawer column down to 0 when the available space is limited,
|
||||
// while col2's minmax base reserves mainMinWidthPx for main first.
|
||||
// The dialog uses tw-w-full so it fills the column without overflowing it.
|
||||
let col3: string;
|
||||
if (!drawerActive) {
|
||||
col3 = "0px";
|
||||
} else if (!drawerPush) {
|
||||
col3 = "1fr";
|
||||
} else if (declaredDrawerWidth > 0) {
|
||||
col3 = `minmax(0px, ${declaredDrawerWidth}px)`;
|
||||
} else {
|
||||
col3 = "auto"; // fallback before dialog's effect declares its width
|
||||
}
|
||||
const col2 = !drawerActive || drawerPush ? `minmax(${mainMinWidthPx}px, 1fr)` : "0px";
|
||||
|
||||
return `${col1} ${col2} ${col3}`;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
const container = this.container().nativeElement;
|
||||
const drawerContainer = this.drawerContainer().nativeElement;
|
||||
|
||||
const update = () => {
|
||||
const rootFontSizePx = getRootFontSizePx();
|
||||
const containerWidth = container.clientWidth;
|
||||
const siderailPx = SIDERAIL_WIDTH_REM * rootFontSizePx;
|
||||
const mainMinPx = MAIN_MIN_WIDTH_REM * rootFontSizePx;
|
||||
const navWidthPx = this.sideNavService.widthRem() * rootFontSizePx;
|
||||
const drawerMinPx = drawerSizeToWidthRem.small * rootFontSizePx;
|
||||
|
||||
this.containerWidthPx.set(containerWidth);
|
||||
|
||||
// Use the push width declared by the drawer content (e.g. bit-dialog) via
|
||||
// DrawerService.declarePushWidth(). This is more reliable than DOM measurement
|
||||
// because the drawerContainer's firstElementChild is the outer portal host
|
||||
// component (e.g. app-vault-item), which fills the full 1fr column in overlay
|
||||
// mode — making its offsetWidth useless for push-vs-overlay decisions.
|
||||
const drawerWidthPx = this.drawerService.pushWidthPx();
|
||||
|
||||
// Can the full nav push alongside main (ignoring the drawer)?
|
||||
const navAloneCanPush = containerWidth - navWidthPx >= mainMinPx;
|
||||
|
||||
// Can the drawer push at full width with the full nav?
|
||||
const drawerFullWidthNavCanPush =
|
||||
drawerWidthPx > 0 && containerWidth - navWidthPx - drawerWidthPx >= mainMinPx;
|
||||
|
||||
// Can the drawer push at full width with just the siderail?
|
||||
const drawerFullWidthSiderailCanPush =
|
||||
drawerWidthPx > 0 && containerWidth - siderailPx - drawerWidthPx >= mainMinPx;
|
||||
|
||||
// Can the drawer push at minimum width with the full nav (shrink zone)?
|
||||
const drawerMinWithNavCanPush =
|
||||
drawerWidthPx > 0 && containerWidth - navWidthPx - drawerMinPx >= mainMinPx;
|
||||
|
||||
// Can the drawer push at minimum width with just the siderail (shrink zone)?
|
||||
const drawerMinWithSiderailCanPush =
|
||||
drawerWidthPx > 0 && containerWidth - siderailPx - drawerMinPx >= mainMinPx;
|
||||
|
||||
// When the drawer is open and space is limited, the full nav yields first —
|
||||
// it closes to its siderail so the drawer can remain in push mode. When even
|
||||
// the minimum push width doesn't fit, the drawer goes overlay.
|
||||
// drawerPush: true if the drawer fits at any width (full or shrunk) alongside either nav or siderail.
|
||||
// navPush: true if the full nav fits alongside the drawer; false if only the siderail fits.
|
||||
// When no drawer is active, falls back to navAloneCanPush.
|
||||
const drawerPush =
|
||||
drawerFullWidthNavCanPush ||
|
||||
drawerFullWidthSiderailCanPush ||
|
||||
drawerMinWithNavCanPush ||
|
||||
drawerMinWithSiderailCanPush;
|
||||
const navPush = drawerPush
|
||||
? drawerFullWidthNavCanPush || drawerMinWithNavCanPush
|
||||
: navAloneCanPush && drawerWidthPx === 0;
|
||||
|
||||
// In shrink-push mode the drawer occupies less than its declared max, so use
|
||||
// the actual available space as the effective drawer width for the siderail check.
|
||||
const drawerEffectivePx = drawerPush
|
||||
? Math.min(drawerWidthPx, Math.max(0, containerWidth - siderailPx - mainMinPx))
|
||||
: 0;
|
||||
const siderailCanPush = drawerPush
|
||||
? containerWidth - siderailPx - drawerEffectivePx >= mainMinPx
|
||||
: containerWidth - siderailPx >= mainMinPx;
|
||||
|
||||
const wasInPushMode = this.sideNavService.isPushMode();
|
||||
|
||||
// Transitioning out of push mode → close the nav.
|
||||
// (If already in overlay and open, leave it — it's intentionally overlaying content.)
|
||||
if (!navPush && this.sideNavService.open() && wasInPushMode) {
|
||||
this.sideNavService.open.set(false);
|
||||
}
|
||||
|
||||
// Transitioning into push mode → reopen unless the user explicitly closed it.
|
||||
if (
|
||||
navPush &&
|
||||
!wasInPushMode &&
|
||||
this.sideNavService.userCollapsePreference() !== "closed"
|
||||
) {
|
||||
this.sideNavService.open.set(true);
|
||||
}
|
||||
|
||||
this.sideNavService.isPushMode.set(navPush);
|
||||
this.siderailIsPushMode.set(siderailCanPush);
|
||||
this.drawerService.isPushMode.set(drawerPush);
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
resizeObserver.observe(container);
|
||||
resizeObserver.observe(drawerContainer);
|
||||
this.destroyRef.onDestroy(() => resizeObserver.disconnect());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounded top left corner for the main content area
|
||||
|
||||
@@ -28,7 +28,6 @@ export class ScrollLayoutService {
|
||||
**/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayoutHost]",
|
||||
standalone: true,
|
||||
host: {
|
||||
class: "cdk-virtual-scrollable",
|
||||
},
|
||||
@@ -55,7 +54,6 @@ export class ScrollLayoutHostDirective implements OnDestroy {
|
||||
*/
|
||||
@Directive({
|
||||
selector: "[bitScrollLayout]",
|
||||
standalone: true,
|
||||
providers: [{ provide: VIRTUAL_SCROLLABLE, useExisting: ScrollLayoutDirective }],
|
||||
})
|
||||
export class ScrollLayoutDirective extends CdkVirtualScrollable implements OnInit {
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import { Meta, Story, Primary, Controls, Title, Description } from "@storybook/addon-docs/blocks";
|
||||
import {
|
||||
Meta,
|
||||
Story,
|
||||
Canvas,
|
||||
Primary,
|
||||
Controls,
|
||||
Title,
|
||||
Description,
|
||||
} from "@storybook/addon-docs/blocks";
|
||||
|
||||
import * as stories from "./link.stories";
|
||||
|
||||
@@ -33,15 +41,25 @@ You can use one of the following variants by providing it as the `linkType` inpu
|
||||
If you want to display a link with a smaller text size, apply the `tw-text-sm` class. This will
|
||||
match the `body2` variant of the Typography directive.
|
||||
|
||||
## With icons
|
||||
## With Icon
|
||||
|
||||
Text Links/buttons can have icons on left or the right.
|
||||
Use the `startIcon` and `endIcon` inputs to add a Bitwarden icon (`bwi-*`) before or after the link
|
||||
label. Do not use a `<bit-icon>` component inside the link as this may not have the correct styling
|
||||
and spacing.
|
||||
|
||||
To indicate a new or add action, the <i class="bwi bwi-plus-circle"></i> icon on is used on the
|
||||
left.
|
||||
### Icon before the label
|
||||
|
||||
An angle icon, <i class="bwi bwi-angle-right"></i>, is used on the left to indicate an expand to
|
||||
show/hide additional content.
|
||||
```html
|
||||
<a bitLink startIcon="bwi-plus-circle">Add item</a>
|
||||
```
|
||||
|
||||
### Icon after the label
|
||||
|
||||
```html
|
||||
<a bitLink endIcon="bwi-angle-right">Next</a>
|
||||
```
|
||||
|
||||
<Canvas of={stories.WithIcons} />
|
||||
|
||||
## Accessibility
|
||||
|
||||
|
||||
@@ -53,7 +53,6 @@ const CONTEXT_MENU_POSITIONS: ConnectedPosition[] = [
|
||||
@Directive({
|
||||
selector: "[bitMenuTriggerFor]",
|
||||
exportAs: "menuTrigger",
|
||||
standalone: true,
|
||||
host: { "[attr.role]": "this.role()" },
|
||||
})
|
||||
export class MenuTriggerForDirective implements OnDestroy {
|
||||
|
||||
@@ -32,6 +32,9 @@
|
||||
[ariaCurrentWhenActive]="ariaCurrentWhenActive()"
|
||||
(isActiveChange)="setIsActive($event)"
|
||||
(click)="mainContentClicked.emit()"
|
||||
[state]="{
|
||||
focusAfterNav: focusAfterNavTarget(),
|
||||
}"
|
||||
>
|
||||
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
|
||||
</a>
|
||||
|
||||
@@ -91,6 +91,18 @@ export class NavItemComponent extends NavBaseComponent {
|
||||
*/
|
||||
readonly ariaCurrentWhenActive = input<RouterLinkActive["ariaCurrentWhenActive"]>("page");
|
||||
|
||||
/**
|
||||
* By default, a navigation will put the user's focus on the `main` element.
|
||||
*
|
||||
* If the user's focus should be moved to another element upon navigation end, pass a selector
|
||||
* here (i.e. `#elementId`).
|
||||
*
|
||||
* Pass `false` to opt out of moving the focus entirely. Focus will stay on the nav item.
|
||||
*
|
||||
* See router-focus-manager.service for implementation of focus management
|
||||
*/
|
||||
readonly focusAfterNavTarget = input<string | boolean>();
|
||||
|
||||
/**
|
||||
* The design spec calls for the an outline to wrap the entire element when the template's
|
||||
* anchor/button has :focus-visible. Usually, we would use :focus-within for this. However, that
|
||||
|
||||
@@ -146,28 +146,3 @@ export const ForceActiveStyles: Story = {
|
||||
`,
|
||||
}),
|
||||
};
|
||||
|
||||
export const CollapsedNavItems: Story = {
|
||||
render: (args) => ({
|
||||
props: args,
|
||||
template: `
|
||||
<bit-nav-item text="First Nav" icon="bwi-collection-shared"></bit-nav-item>
|
||||
<bit-nav-item text="Active Nav" icon="bwi-collection-shared" [forceActiveStyles]="true"></bit-nav-item>
|
||||
<bit-nav-item text="Third Nav" icon="bwi-collection-shared"></bit-nav-item>
|
||||
`,
|
||||
}),
|
||||
play: async () => {
|
||||
const toggleButton = document.querySelector(
|
||||
"[aria-label='Toggle side navigation']",
|
||||
) as HTMLButtonElement;
|
||||
|
||||
if (toggleButton) {
|
||||
toggleButton.click();
|
||||
}
|
||||
},
|
||||
parameters: {
|
||||
chromatic: {
|
||||
delay: 1000,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div
|
||||
class="tw-px-2 tw-pt-2"
|
||||
class="tw-px-2 tw-pt-[calc(0.5rem_+_var(--bit-sidenav-macos-extra-top-padding,_0px))] tw-bg-bg-sidenav"
|
||||
[class]="
|
||||
sideNavService.open()
|
||||
? 'tw-sticky tw-top-0 tw-z-50 tw-pb-4'
|
||||
|
||||
@@ -12,6 +12,7 @@ import { SideNavService } from "./side-nav.service";
|
||||
templateUrl: "./nav-logo.component.html",
|
||||
imports: [RouterLinkActive, RouterLink, SvgComponent],
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
host: { class: "tw-contents" },
|
||||
})
|
||||
export class NavLogoComponent {
|
||||
protected readonly sideNavService = inject(SideNavService);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<div class="tw-relative tw-h-full">
|
||||
<nav
|
||||
id="bit-side-nav"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overscroll-none tw-overflow-auto tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
class="tw-sticky tw-inset-y-0 tw-left-0 tw-z-30 tw-flex tw-h-full tw-flex-col tw-overflow-hidden tw-bg-bg-sidenav tw-text-fg-sidenav-text tw-outline-none"
|
||||
[style.width.rem]="open ? (sideNavService.width$ | async) : undefined"
|
||||
[style]="
|
||||
variant() === 'secondary'
|
||||
@@ -16,12 +16,24 @@
|
||||
[attr.aria-modal]="isOverlay ? 'true' : null"
|
||||
(keydown)="handleKeyDown($event)"
|
||||
>
|
||||
<ng-content></ng-content>
|
||||
<!-- 53rem = ~850px -->
|
||||
<!-- This is a magic number. This number was selected by going to the UI and finding the number that felt the best to me and design. No real rhyme or reason :) -->
|
||||
<div
|
||||
class="[@media(min-height:53rem)]:tw-sticky tw-bottom-0 tw-left-0 tw-z-20 tw-mt-auto tw-w-full tw-bg-bg-sidenav"
|
||||
class="tw-flex-1 tw-min-h-0 tw-overflow-auto tw-overscroll-none"
|
||||
style="container-type: size"
|
||||
>
|
||||
<div class="tw-flex tw-flex-col tw-min-h-full">
|
||||
<div class="tw-flex-1">
|
||||
<ng-content></ng-content>
|
||||
</div>
|
||||
@if (open) {
|
||||
<div
|
||||
class="tw-w-full tw-bg-bg-sidenav [@container_(min-height:600px)]:tw-sticky [@container_(min-height:600px)]:tw-bottom-0"
|
||||
>
|
||||
<ng-content select="[slot=product-switcher]"></ng-content>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="tw-w-full tw-bg-bg-sidenav">
|
||||
<bit-nav-divider></bit-nav-divider>
|
||||
@if (open) {
|
||||
<ng-content select="[slot=footer]"></ng-content>
|
||||
|
||||
@@ -34,7 +34,12 @@ export type SideNavVariant = "primary" | "secondary";
|
||||
AsyncPipe,
|
||||
],
|
||||
host: {
|
||||
class: "tw-block tw-h-full",
|
||||
// Grid placement: always col 1. In overlay mode the element is also
|
||||
// switched to position:fixed so it escapes the grid's stacking context
|
||||
// and renders above the scrim (z-40) and the drawer.
|
||||
class: "tw-block tw-h-full tw-col-start-1 tw-row-start-1",
|
||||
"[class]":
|
||||
"sideNavService.isOverlay() ? 'tw-fixed tw-top-0 tw-bottom-0 tw-left-0 tw-z-50' : ''",
|
||||
},
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
})
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
import { computed, effect, inject, Injectable, signal } from "@angular/core";
|
||||
import { computed, inject, Injectable, signal } from "@angular/core";
|
||||
import { takeUntilDestroyed, toSignal } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, Observable, fromEvent, map, startWith, debounceTime, first } from "rxjs";
|
||||
|
||||
import { BIT_SIDE_NAV_DISK, GlobalStateProvider, KeyDefinition } from "@bitwarden/state";
|
||||
|
||||
import { BREAKPOINTS, isAtOrLargerThanBreakpoint } from "../utils/responsive-utils";
|
||||
|
||||
type CollapsePreference = "open" | "closed" | null;
|
||||
import { getRootFontSizePx } from "../shared";
|
||||
|
||||
const BIT_SIDE_NAV_WIDTH_KEY_DEF = new KeyDefinition<number>(BIT_SIDE_NAV_DISK, "side-nav-width", {
|
||||
deserializer: (s) => s,
|
||||
@@ -26,14 +24,24 @@ export class SideNavService {
|
||||
/**
|
||||
* Whether the side navigation is open or closed.
|
||||
*/
|
||||
readonly open = signal(isAtOrLargerThanBreakpoint("md"));
|
||||
readonly open = signal(false);
|
||||
|
||||
private isLargeScreen$ = media(`(min-width: ${BREAKPOINTS.md}px)`);
|
||||
readonly isLargeScreen = toSignal(this.isLargeScreen$, { requireSync: true });
|
||||
/**
|
||||
* Whether the nav is in push mode (occupies its own grid column).
|
||||
* Set by LayoutComponent via ResizeObserver.
|
||||
*/
|
||||
readonly isPushMode = signal(false);
|
||||
|
||||
readonly userCollapsePreference = signal<CollapsePreference>(null);
|
||||
/**
|
||||
* True when the nav is open but not in push mode — it overlays the content.
|
||||
*/
|
||||
readonly isOverlay = computed(() => this.open() && !this.isPushMode());
|
||||
|
||||
readonly isOverlay = computed(() => this.open() && !this.isLargeScreen());
|
||||
/**
|
||||
* Explicit user preference for open/closed state, set when the user manually
|
||||
* toggles the nav. Null means no preference (auto-open when push mode allows).
|
||||
*/
|
||||
readonly userCollapsePreference = signal<"open" | "closed" | null>(null);
|
||||
|
||||
/**
|
||||
* Local component state width
|
||||
@@ -43,6 +51,9 @@ export class SideNavService {
|
||||
private readonly _width$ = new BehaviorSubject<number>(this.DEFAULT_OPEN_WIDTH);
|
||||
readonly width$ = this._width$.asObservable();
|
||||
|
||||
/** Current nav width as a signal, for use in grid column calculations. */
|
||||
readonly widthRem = toSignal(this.width$, { initialValue: this.DEFAULT_OPEN_WIDTH });
|
||||
|
||||
/**
|
||||
* State provider width
|
||||
*
|
||||
@@ -56,17 +67,7 @@ export class SideNavService {
|
||||
|
||||
constructor() {
|
||||
// Get computed root font size to support user-defined a11y font increases
|
||||
this.rootFontSizePx = parseFloat(getComputedStyle(document.documentElement).fontSize || "16");
|
||||
|
||||
// Handle open/close state
|
||||
effect(() => {
|
||||
if (!this.isLargeScreen()) {
|
||||
this.open.set(false);
|
||||
} else if (this.userCollapsePreference() !== "closed") {
|
||||
// Auto-open when user hasn't set preference (null) or prefers open
|
||||
this.open.set(true);
|
||||
}
|
||||
});
|
||||
this.rootFontSizePx = getRootFontSizePx();
|
||||
|
||||
// Initialize the resizable width from state provider
|
||||
this.widthState$.pipe(first()).subscribe((width: number) => {
|
||||
@@ -83,9 +84,7 @@ export class SideNavService {
|
||||
* Toggle the open/close state of the side nav
|
||||
*/
|
||||
toggle() {
|
||||
// Store user's preference based on what state they're toggling TO
|
||||
this.userCollapsePreference.set(this.open() ? "closed" : "open");
|
||||
|
||||
this.open.set(!this.open());
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ import { PopoverComponent } from "./popover.component";
|
||||
* - tick(0) flushes microtasks, useful for Angular effects that run synchronously
|
||||
*/
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: `
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { Directive, ElementRef, EventEmitter, Output, OnDestroy } from "@angular
|
||||
|
||||
@Directive({
|
||||
selector: "[resizeObserver]",
|
||||
standalone: true,
|
||||
})
|
||||
export class ResizeObserverDirective implements OnDestroy {
|
||||
private observer = new ResizeObserver((entries) => {
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./root-font-size";
|
||||
export * from "./shared.module";
|
||||
|
||||
7
libs/components/src/shared/root-font-size.ts
Normal file
7
libs/components/src/shared/root-font-size.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Returns the root font size in pixels, falling back to 16 if unavailable (e.g. SSR).
|
||||
*/
|
||||
export const getRootFontSizePx = (): number =>
|
||||
typeof document !== "undefined"
|
||||
? parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
: 16;
|
||||
@@ -8,7 +8,6 @@ import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.servic
|
||||
@Component({
|
||||
selector: "bit-spinner",
|
||||
templateUrl: "spinner.component.html",
|
||||
standalone: true,
|
||||
imports: [CommonModule],
|
||||
})
|
||||
export class SpinnerComponent {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { Component, input } from "@angular/core";
|
||||
selector: "bit-step",
|
||||
templateUrl: "step.component.html",
|
||||
providers: [{ provide: CdkStep, useExisting: StepComponent }],
|
||||
standalone: true,
|
||||
})
|
||||
export class StepComponent extends CdkStep {
|
||||
readonly subLabel = input();
|
||||
|
||||
@@ -19,7 +19,6 @@ import { StepComponent } from "./step.component";
|
||||
templateUrl: "stepper.component.html",
|
||||
providers: [{ provide: CdkStepper, useExisting: StepperComponent }],
|
||||
imports: [CommonModule, ResizeObserverDirective, TypographyModule],
|
||||
standalone: true,
|
||||
})
|
||||
export class StepperComponent extends CdkStepper {
|
||||
// Need to reimplement the constructor to fix an invalidFactoryDep error in Storybook
|
||||
|
||||
@@ -11,7 +11,6 @@ import { TableDataSource, TableModule } from "../../../table";
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
selector: "dialog-virtual-scroll-block",
|
||||
standalone: true,
|
||||
imports: [
|
||||
DialogModule,
|
||||
IconButtonModule,
|
||||
|
||||
@@ -2,14 +2,7 @@ import { importProvidersFrom } from "@angular/core";
|
||||
import { provideNoopAnimations } from "@angular/platform-browser/animations";
|
||||
import { RouterModule } from "@angular/router";
|
||||
import { Meta, StoryObj, applicationConfig, moduleMetadata } from "@storybook/angular";
|
||||
import {
|
||||
userEvent,
|
||||
getAllByRole,
|
||||
getByRole,
|
||||
fireEvent,
|
||||
getByText,
|
||||
getAllByLabelText,
|
||||
} from "storybook/test";
|
||||
import { userEvent, getAllByRole, getByRole, fireEvent, getAllByLabelText } from "storybook/test";
|
||||
|
||||
import { PasswordManagerLogo } from "@bitwarden/assets/svg";
|
||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||
@@ -90,6 +83,22 @@ export default {
|
||||
|
||||
type Story = StoryObj<LayoutComponent>;
|
||||
|
||||
type KitchenSinkRoute = "/bitwarden" | "/virtual-scroll";
|
||||
|
||||
async function navigateTo(path: KitchenSinkRoute) {
|
||||
window.location.hash = path;
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
/** Waits for the ResizeObserver + Angular CD to settle, then opens the side nav if it's closed. */
|
||||
async function openSideNav(canvas: HTMLElement) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
const toggleButton = getByRole(canvas, "button", { name: "Toggle side navigation" });
|
||||
if (toggleButton.getAttribute("aria-expanded") === "false") {
|
||||
await userEvent.click(toggleButton);
|
||||
}
|
||||
}
|
||||
|
||||
export const Default: Story = {
|
||||
render: (args) => {
|
||||
return {
|
||||
@@ -130,6 +139,7 @@ export const MenuOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const table = getByRole(canvas, "table");
|
||||
|
||||
const menuButton = getAllByRole(table, "button")[0];
|
||||
@@ -144,6 +154,7 @@ export const DialogOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const dialogButton = getByRole(canvas, "button", {
|
||||
name: "Open Dialog",
|
||||
});
|
||||
@@ -157,6 +168,7 @@ export const DrawerOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const drawerButton = getByRole(canvas, "button", {
|
||||
name: "Open Drawer",
|
||||
});
|
||||
@@ -170,6 +182,7 @@ export const PopoverOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const popoverLink = getByRole(canvas, "button", {
|
||||
name: "Popover trigger link",
|
||||
});
|
||||
@@ -182,6 +195,7 @@ export const SimpleDialogOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const submitButton = getByRole(canvas, "button", {
|
||||
name: "Submit",
|
||||
});
|
||||
@@ -195,6 +209,7 @@ export const EmptyTab: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
const emptyTab = getByRole(canvas, "tab", { name: "Empty tab" });
|
||||
await userEvent.click(emptyTab);
|
||||
},
|
||||
@@ -204,8 +219,7 @@ export const VirtualScrollBlockingDialog: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
const navItem = getByText(canvas, "Virtual Scroll");
|
||||
await userEvent.click(navItem);
|
||||
await navigateTo("/virtual-scroll");
|
||||
|
||||
const htmlEl = canvas.ownerDocument.documentElement;
|
||||
htmlEl.scrollTop = 2000;
|
||||
@@ -216,11 +230,38 @@ export const VirtualScrollBlockingDialog: Story = {
|
||||
},
|
||||
};
|
||||
|
||||
export const SideNavOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
await navigateTo("/bitwarden");
|
||||
await openSideNav(canvas);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { viewports: [640, 1024, 1280] },
|
||||
},
|
||||
};
|
||||
|
||||
export const DrawerOpenBeforeSideNavOpen: Story = {
|
||||
render: Default.render,
|
||||
play: async (context) => {
|
||||
const canvas = context.canvasElement;
|
||||
// workaround for userEvent not firing in FF https://github.com/testing-library/user-event/issues/1075
|
||||
await fireEvent.click(getByRole(canvas, "button", { name: "Open Drawer" }));
|
||||
|
||||
await navigateTo("/bitwarden");
|
||||
await openSideNav(canvas);
|
||||
},
|
||||
parameters: {
|
||||
chromatic: { viewports: [640, 1024, 1280, 1440] },
|
||||
},
|
||||
};
|
||||
|
||||
export const ResponsiveSidebar: Story = {
|
||||
render: Default.render,
|
||||
parameters: {
|
||||
chromatic: {
|
||||
viewports: [640, 1280],
|
||||
viewports: [640, 1024, 1280, 1440],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -91,7 +91,6 @@ import { SvgModule } from "@bitwarden/components";
|
||||
|
||||
@Component({
|
||||
selector: "app-example",
|
||||
standalone: true,
|
||||
imports: [SvgModule],
|
||||
templateUrl: "./example.component.html",
|
||||
})
|
||||
|
||||
@@ -76,7 +76,6 @@ describe("SwitchComponent", () => {
|
||||
@Component({
|
||||
selector: "test-selected-host",
|
||||
template: `<bit-switch [selected]="checked"><bit-label>Element</bit-label></bit-switch>`,
|
||||
standalone: true,
|
||||
imports: [SwitchComponent, BitLabelComponent],
|
||||
})
|
||||
class TestSelectedHostComponent {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
[routerLinkActiveOptions]="routerLinkMatchOptions"
|
||||
#rla="routerLinkActive"
|
||||
[active]="rla.isActive"
|
||||
[state]="{ focusMainAfterNav: false }"
|
||||
[state]="{ focusAfterNav: false }"
|
||||
[disabled]="disabled"
|
||||
[attr.aria-disabled]="disabled"
|
||||
ariaCurrentWhenActive="page"
|
||||
|
||||
@@ -15,7 +15,6 @@ import { TooltipDirective, TOOLTIP_DELAY_MS } from "./tooltip.directive";
|
||||
// FIXME(https://bitwarden.atlassian.net/browse/CL-764): Migrate to OnPush
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
|
||||
@Component({
|
||||
standalone: true,
|
||||
imports: [TooltipDirective],
|
||||
template: ` <button [bitTooltip]="tooltipText" type="button">Hover or focus me</button> `,
|
||||
})
|
||||
|
||||
@@ -651,4 +651,8 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
router-outlet {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user