1
0
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:
Vicki League
2026-02-27 10:56:25 -05:00
809 changed files with 34107 additions and 9716 deletions

View 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.

View 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();
});
});
});

View File

@@ -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}"`);
}
}
}

View File

@@ -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: "**",

View File

@@ -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 [

View File

@@ -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>

View File

@@ -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);

View File

@@ -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} />

View File

@@ -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);
}

View File

@@ -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);

View 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();
});
});
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -7,7 +7,6 @@ import { TypographyDirective } from "../typography/typography.directive";
templateUrl: "./header.component.html",
imports: [TypographyDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
standalone: true,
})
export class HeaderComponent {
/**

View File

@@ -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),
);
}

View File

@@ -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

View File

@@ -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>`,

View File

@@ -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]",

View File

@@ -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",
})

View File

@@ -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>

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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 {

View File

@@ -32,6 +32,9 @@
[ariaCurrentWhenActive]="ariaCurrentWhenActive()"
(isActiveChange)="setIsActive($event)"
(click)="mainContentClicked.emit()"
[state]="{
focusAfterNav: focusAfterNavTarget(),
}"
>
<ng-container *ngTemplateOutlet="anchorAndButtonContent"></ng-container>
</a>

View File

@@ -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

View File

@@ -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,
},
},
};

View File

@@ -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'

View File

@@ -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);

View File

@@ -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>

View File

@@ -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,
})

View File

@@ -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());
}

View File

@@ -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"

View File

@@ -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) => {

View File

@@ -1 +1,2 @@
export * from "./root-font-size";
export * from "./shared.module";

View 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;

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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

View File

@@ -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,

View File

@@ -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],
},
},
};

View File

@@ -91,7 +91,6 @@ import { SvgModule } from "@bitwarden/components";
@Component({
selector: "app-example",
standalone: true,
imports: [SvgModule],
templateUrl: "./example.component.html",
})

View File

@@ -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 {

View File

@@ -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"

View File

@@ -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> `,
})

View File

@@ -651,4 +651,8 @@
height: 100%;
overflow: hidden;
}
router-outlet {
display: none;
}
}