Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
<bit-header [title]="resolvedTitle()" [icon]="icon()">
<ng-container slot="title-icon">
<ng-content select="[slot=title-icon]" />
</ng-container>

<ng-container slot="breadcrumbs">
<ng-content select="[slot=breadcrumbs]" />
</ng-container>
Expand Down
15 changes: 14 additions & 1 deletion apps/desktop/src/vault/app/vault-v3/vault.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,20 @@
>
<div [ngClass]="vfo1Foundation() ? 'tw-pt-6 tw-px-8' : 'tw-pt-6 tw-px-6'">
<vault-organization-user-notifications></vault-organization-user-notifications>
<app-header>
<app-header [title]="title()">
@if (organizationScoped()) {
<vault-breadcrumbs slot="breadcrumbs" [scope]="vaultScope()" />
} @else if (headerTile(); as tile) {
<bit-icon-tile
slot="title-icon"
[icon]="tile.icon"
[variant]="tile.variant ?? 'primary'"
[color]="tile.color"
[emphasis]="tile.emphasis ?? 'subtle'"
size="sm"
aria-hidden="true"
/>
}
@if (!vfo1Foundation()) {
<vault-new-cipher-menu
[canCreateCipher]="(canCreateCipher$ | async) ?? true"
Expand Down
36 changes: 36 additions & 0 deletions apps/desktop/src/vault/app/vault-v3/vault.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ import {
ToastService,
SearchModule,
AutofocusDirective,
IconTileComponent,
} from "@bitwarden/components";
import {
AddEditFolderDialogComponent,
Expand Down Expand Up @@ -126,7 +127,10 @@ import {
resolveVaultScope,
scopedCollectionSegment,
SharedFolderCardGridComponent,
VaultBreadcrumbsComponent,
VaultNavService,
vaultScopeHeaderTile,
vaultScopeTitle,
VaultScopeType,
} from "@bitwarden/vault";

Expand Down Expand Up @@ -169,6 +173,8 @@ type EmptyStateMap = Record<EmptyStateType, EmptyStateItem>;
VaultOrganizationUserNotificationsComponent,
AutofocusDirective,
SharedFolderCardGridComponent,
VaultBreadcrumbsComponent,
IconTileComponent,
],
providers: [
{ provide: VaultItemsTransferService, useClass: DefaultVaultItemsTransferService },
Expand Down Expand Up @@ -322,6 +328,36 @@ export class VaultComponent<C extends CipherViewLike> implements OnInit, OnDestr
}),
);

private readonly vaultNav$ = this.userId$.pipe(
switchMap((userId) => this.vaultNavService.viewModel$(userId)),
);

/** The scope's page title under VFO1; unset otherwise so the header keeps its route title. */
protected readonly title = toSignal(
combineLatest([this.vfo1Foundation$, this.vaultScope$, this.selectedOrganization$]).pipe(
map(([vfo1Foundation, scope, organization]) =>
vfo1Foundation ? vaultScopeTitle(scope, this.i18nService, organization?.name) : undefined,
),
),
);

protected readonly headerTile = toSignal(
combineLatest([this.vfo1Foundation$, this.vaultScope$, this.vaultNav$]).pipe(
map(([vfo1Foundation, scope, nav]) =>
vfo1Foundation ? vaultScopeHeaderTile(scope, nav) : undefined,
),
),
);

protected readonly organizationScoped = toSignal(
combineLatest([this.vfo1Foundation$, this.vaultScope$]).pipe(
map(
([vfo1Foundation, scope]) => vfo1Foundation && scope.type === VaultScopeType.Organization,
),
),
{ initialValue: false },
);

protected readonly showAddCipherBtn$ = combineLatest([
this.vfo1Foundation$,
this.vaultScope$,
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/app/layouts/header/web-header.component.html
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
@let routeData = routeData$ | async;
@if (routeData) {
<bit-header [title]="title() || (routeData.titleId | i18n)" [icon]="icon()">
<ng-container slot="title-icon">
<ng-content select="[slot=title-icon]"></ng-content>
</ng-container>

<ng-container slot="breadcrumbs">
<ng-content select="[slot=breadcrumbs]"></ng-content>
</ng-container>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,16 @@
<app-header [title]="title()">
@if (collectionSelected()) {
<vault-collection-breadcrumbs
slot="breadcrumbs"
[scope]="parsedVaultScope()"
></vault-collection-breadcrumbs>
@if (organizationScoped()) {
<vault-breadcrumbs slot="breadcrumbs" [scope]="parsedVaultScope()"></vault-breadcrumbs>
} @else if (headerTile(); as tile) {
<bit-icon-tile
slot="title-icon"
[icon]="tile.icon"
[variant]="tile.variant ?? 'primary'"
[color]="tile.color"
[emphasis]="tile.emphasis ?? 'subtle'"
size="sm"
aria-hidden="true"
/>
}
</app-header>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,9 +356,9 @@ describe("VaultNextComponent", () => {
expect(organizationIds()).toEqual([organizationId, otherOrganizationId]);
});

it("leaves the search index unscoped and the header on its route title", () => {
it("leaves the search index unscoped and titles the header All items", () => {
expect(component().scopedOrganizationId()).toBeUndefined();
expect(component().title()).toBeUndefined();
expect(component().title()).toBe("allItems");
});

it("offers Import and New item", () => {
Expand Down Expand Up @@ -390,9 +390,9 @@ describe("VaultNextComponent", () => {
fixture.detectChanges();
});

it("stays on All items, with the header on its route title", () => {
it("stays on All items, titling the header All items", () => {
expect(component().vaultScope()).toEqual({ type: "allItems" });
expect(component().title()).toBeUndefined();
expect(component().title()).toBe("allItems");
});
});
});
Expand Down Expand Up @@ -489,6 +489,19 @@ describe("VaultNextComponent", () => {
it("titles the header with the organization name", () => {
expect(component().title()).toBe("Acme corporation");
});

it("shows breadcrumbs rather than a header tile", () => {
expect(component().organizationScoped()).toBe(true);
expect(component().headerTile()).toBeUndefined();
});
});

describe("scoped to an organization's My items", () => {
beforeEach(() => scopeTo(organizationId, MY_ITEMS_ROUTE));

it("counts as an organization scope, so it shows breadcrumbs", () => {
expect(component().organizationScoped()).toBe(true);
});
});

it("falls back to every active item when the segment names no destination", () => {
Expand Down
39 changes: 16 additions & 23 deletions apps/web/src/app/vault/individual-vault/vault-next.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { CipherType } from "@bitwarden/common/vault/enums";
import { RestrictedItemTypesService } from "@bitwarden/common/vault/services/restricted-item-types.service";
import { CipherViewLike } from "@bitwarden/common/vault/utils/cipher-view-like-utils";
import { filterOutNullish } from "@bitwarden/common/vault/utils/observable-utilities";
import { ButtonModule, DialogService } from "@bitwarden/components";
import { ButtonModule, DialogService, IconTileComponent } from "@bitwarden/components";
import { PolicyType } from "@bitwarden/sdk-internal";
import { I18nPipe, safeProvider } from "@bitwarden/ui-common";
import {
Expand All @@ -28,7 +28,7 @@ import {
NewCipherMenuComponent,
SharedFolderCardGridComponent,
VaultCopyButtonsService,
VaultCollectionBreadcrumbsComponent,
VaultBreadcrumbsComponent,
VaultItemsTableComponent,
VaultItemsTableCopyPresentation,
VaultItemsTableRowAction,
Expand All @@ -37,11 +37,12 @@ import {
ALL_ITEMS_SCOPE,
cipherInScope,
collectionInScope,
MY_ITEMS_ROUTE,
organizationInScope,
parseVaultScope,
resolveVaultScope,
scopedCollectionSegment,
vaultScopeHeaderTile,
vaultScopeTitle,
VaultScopeType,
} from "@bitwarden/vault";

Expand Down Expand Up @@ -75,7 +76,8 @@ import { VaultOnboardingComponent } from "./vault-onboarding/vault-onboarding.co
HeaderModule,
NewCipherMenuComponent,
VaultBannersComponent,
VaultCollectionBreadcrumbsComponent,
VaultBreadcrumbsComponent,
IconTileComponent,
VaultItemsTableComponent,
VaultOnboardingComponent,
VaultOrganizationUserNotificationsComponent,
Expand Down Expand Up @@ -132,10 +134,13 @@ export class VaultNextComponent {
() => parseVaultScope(this.vaultIdParam(), this.collectionSegment()) ?? ALL_ITEMS_SCOPE,
);

protected readonly collectionSelected = computed(() => {
const seg = this.collectionSegment();
return seg != null && seg !== MY_ITEMS_ROUTE;
});
protected readonly organizationScoped = computed(
() => this.parsedVaultScope().type === VaultScopeType.Organization,
);

protected readonly headerTile = computed(() =>
vaultScopeHeaderTile(this.vaultScope(), this.vaultNav()),
);

/**
* Every item the user can see, in every state. Which of trashed, archived, and active items a
Expand Down Expand Up @@ -241,21 +246,9 @@ export class VaultNextComponent {
return type !== VaultScopeType.Trash && type !== VaultScopeType.Archive;
});

protected readonly title = computed(() => {
const scope = this.vaultScope();
switch (scope.type) {
case VaultScopeType.MyVault:
return this.i18nService.t("myVault");
case VaultScopeType.Organization:
return this.scopedOrganizations()[0]?.name;
case VaultScopeType.Trash:
return this.i18nService.t("trash");
case VaultScopeType.Archive:
return this.i18nService.t("archiveNoun");
default:
return undefined;
}
});
protected readonly title = computed(() =>
vaultScopeTitle(this.vaultScope(), this.i18nService, this.scopedOrganizations()[0]?.name),
);

protected readonly copyPresentation = toSignal(
this.copyButtonsService.showQuickCopyActions$.pipe(
Expand Down
15 changes: 9 additions & 6 deletions libs/components/src/header/header.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,18 @@
<h1
bitTypography="h1"
noMargin
class="tw-m-0 tw-leading-10 tw-flex tw-gap-1"
class="tw-m-0 tw-leading-10 tw-flex tw-items-center tw-gap-1"
[title]="title()"
>
<div class="tw-truncate">
@if (icon()) {
<bit-icon [name]="icon()" />
}
<div class="tw-flex tw-min-w-0 tw-items-center tw-gap-3">
<ng-content select="[slot=title-icon]"></ng-content>
<div class="tw-truncate">
@if (icon()) {
<bit-icon [name]="icon()" />
}

{{ title() }}
{{ title() }}
</div>
</div>
<div class="tw-flex tw-items-center">
<ng-container *ngTemplateOutlet="titleSuffix"></ng-container>
Expand Down
28 changes: 19 additions & 9 deletions libs/components/src/header/header.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ the required `title` input, prefixed by `icon` and followed by anything in `slot
When a `bit-breadcrumbs` projected into the header has an **active** breadcrumb β€” one whose `route`
matches the current URL β€” that breadcrumb becomes the page's `<h1>` instead, marked
`aria-current="page"`. The header then skips its own heading so the page never has two `<h1>`
elements, which means `title`, `icon`, and `slot="title-suffix"` don't render in this case.
elements, which means `title`, `icon`, `slot="title-icon"`, and `slot="title-suffix"` don't render
in this case.

<Canvas of={stories.WithActiveBreadcrumbVfo1} />

Expand All @@ -50,14 +51,15 @@ Without it the breadcrumbs have no context to register with.

## Content slots

| Slot | Description |
| --------------------- | ---------------------------------------------------------------------- |
| default | controls that act on the page, aligned to the end of the title row |
| `slot="breadcrumbs"` | a `bit-breadcrumbs` showing where the page sits in the hierarchy |
| `slot="title-suffix"` | content appended inline to the title, typically an icon or icon button |
| `slot="subtitle"` | additional page information to complement the page title |
| `slot="tabs"` | a `bit-tab-nav-bar` for navigation within the page |
| `slot="secondary"` | **Deprecated.** A second row of controls below the default slot |
| Slot | Description |
| --------------------- | ------------------------------------------------------------------------ |
| default | controls that act on the page, aligned to the end of the title row |
| `slot="breadcrumbs"` | a `bit-breadcrumbs` showing where the page sits in the hierarchy |
| `slot="title-icon"` | a leading icon tile shown before the title, in place of the `icon` input |
| `slot="title-suffix"` | content appended inline to the title, typically an icon or icon button |
| `slot="subtitle"` | additional page information to complement the page title |
| `slot="tabs"` | a `bit-tab-nav-bar` for navigation within the page |
| `slot="secondary"` | **Deprecated.** A second row of controls below the default slot |

### Default slot

Expand All @@ -73,6 +75,14 @@ Breadcrumbs render above the title. An active breadcrumb also changes what the h

<Canvas of={stories.BreadcrumbsWithPrimaryContentVfo1} />

### Title icon

A leading icon tile before the title, for a page that identifies itself with a `bit-icon-tile`
rather than the monochrome `icon` input. Like the title suffix, it is part of the heading and drops
when a breadcrumb is promoted. Only renders while the `VFO1Foundation` feature flag is on.

<Canvas of={stories.WithTitleIconVfo1} />

### Title suffix

The title suffix is part of the heading, so it is rendered only when the header renders its own
Expand Down
14 changes: 14 additions & 0 deletions libs/components/src/header/header.stories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
TabsModule,
TypographyModule,
IconComponent,
IconTileComponent,
} from "..";
import { I18nMockService } from "../utils";

Expand All @@ -44,6 +45,7 @@ export default {
ButtonModule,
IconButtonModule,
IconComponent,
IconTileComponent,
SvgModule,
InputModule,
MenuModule,
Expand Down Expand Up @@ -349,6 +351,18 @@ export const WithTabsVfo1: Story = {
globals: enabledFlags(FeatureFlag.VFO1Foundation),
};

export const WithTitleIconVfo1: Story = {
render: (args) => ({
props: args,
template: /*html*/ `
<bit-header title="My vault" class="tw-text-main">
<bit-icon-tile slot="title-icon" icon="bwi-user" variant="brand" emphasis="bold" size="sm" />
</bit-header>
`,
}),
globals: enabledFlags(FeatureFlag.VFO1Foundation),
};

export const WithTitleSuffixComponent: Story = {
render: (args) => ({
props: args,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<bit-breadcrumbs>
<bit-breadcrumb [route]="orgRootCrumbRoute()">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ IMPORTANT: On an organization's All vault items page this crumb renders as a focusable button that does nothing.

Details and fix

orgRootCrumbRoute() is undefined when collectionId() is null, which is exactly the All vault items page β€” a page that previously showed no breadcrumbs at all, so this is new behavior.

bit-breadcrumbs renders a crumb three ways (breadcrumbs.component.html:11-48): active β†’ <span>/<h1>, has a route β†’ <a>, otherwise β†’ <button type="button" (click)="breadcrumb.onClick($event)">. With no route and no (click) bound here, the organization name becomes a tab stop announced as "button", carrying the hover:!tw-text-fg-brand link affordance, that does nothing when activated. The same applies to its collapsed form in the overflow menu (breadcrumbs.component.html:118-122).

Rendering it as static text would fix both β€” for example a text/static mode on bit-breadcrumb that reuses the active-crumb <span> branch without aria-current, used when orgRootCrumbRoute() is unset.

(Restoring the route is not the fix β€” on a URL with no query params the org crumb would then also match router.isActive, and both it and the All vault items crumb would render as <h1 aria-current="page">.)

<bit-icon-tile
slot="start"
[icon]="orgTile()?.icon"
[variant]="orgTile()?.variant ?? 'primary'"
[emphasis]="orgTile()?.emphasis ?? 'subtle'"
aria-hidden="true"
/>
{{ orgNavItem()?.label }}
</bit-breadcrumb>
@for (crumb of trailCrumbs(); track crumb.key) {
<bit-breadcrumb
[icon]="crumb.icon"
[route]="crumb.route"
[queryParamsHandling]="crumb.queryParamsHandling"
>
{{ crumb.label }}
</bit-breadcrumb>
}
</bit-breadcrumbs>
Loading
Loading