diff --git a/apps/desktop/src/locales/en/messages.json b/apps/desktop/src/locales/en/messages.json index edf9392c86a3..6f1c820592d0 100644 --- a/apps/desktop/src/locales/en/messages.json +++ b/apps/desktop/src/locales/en/messages.json @@ -110,6 +110,90 @@ "sharedFolders": { "message": "Shared folders" }, + "collectionsInParent": { + "message": "Collections in $NAME$", + "description": "Titles the grid of collections nested directly inside the collection being viewed.", + "placeholders": { + "name": { + "content": "$1", + "example": "Engineering" + } + } + }, + "sharedFoldersInParent": { + "message": "Shared folders in $NAME$", + "description": "Titles the grid of shared folders nested directly inside the shared folder being viewed.", + "placeholders": { + "name": { + "content": "$1", + "example": "Engineering" + } + } + }, + "collectionCount": { + "message": "$COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "16" + } + } + }, + "collectionCountSingular": { + "message": "$COUNT$ collection", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "sharedFolderCount": { + "message": "$COUNT$ shared folders", + "placeholders": { + "count": { + "content": "$1", + "example": "16" + } + } + }, + "sharedFolderSingular": { + "message": "$COUNT$ shared folder", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "moreCollectionsShownAbove": { + "message": "$COUNT$ more collections shown above this button", + "description": "Announced to screen readers when a collapsed grid of collections is expanded. The revealed collections render above the button that expanded them.", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "moreCollectionsShownAboveSingular": { + "message": "1 more collection shown above this button", + "description": "Announced to screen readers when a collapsed grid of collections is expanded. The revealed collections render above the button that expanded them." + }, + "moreSharedFoldersShownAbove": { + "message": "$COUNT$ more shared folders shown above this button", + "description": "Announced to screen readers when a collapsed grid of shared folders is expanded. The revealed shared folders render above the button that expanded them.", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "moreSharedFoldersShownAboveSingular": { + "message": "1 more shared folder shown above this button", + "description": "Announced to screen readers when a collapsed grid of shared folders is expanded. The revealed shared folders render above the button that expanded them." + }, "myFolder": { "message": "My folder" }, diff --git a/apps/web/src/locales/en/messages.json b/apps/web/src/locales/en/messages.json index 3105fda427c9..d8faa7ea8bdf 100644 --- a/apps/web/src/locales/en/messages.json +++ b/apps/web/src/locales/en/messages.json @@ -1200,6 +1200,90 @@ "sharedFolders": { "message": "Shared folders" }, + "collectionsInParent": { + "message": "Collections in $NAME$", + "description": "Titles the grid of collections nested directly inside the collection being viewed.", + "placeholders": { + "name": { + "content": "$1", + "example": "Engineering" + } + } + }, + "sharedFoldersInParent": { + "message": "Shared folders in $NAME$", + "description": "Titles the grid of shared folders nested directly inside the shared folder being viewed.", + "placeholders": { + "name": { + "content": "$1", + "example": "Engineering" + } + } + }, + "collectionCount": { + "message": "$COUNT$ collections", + "placeholders": { + "count": { + "content": "$1", + "example": "16" + } + } + }, + "collectionCountSingular": { + "message": "$COUNT$ collection", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "sharedFolderCount": { + "message": "$COUNT$ shared folders", + "placeholders": { + "count": { + "content": "$1", + "example": "16" + } + } + }, + "sharedFolderSingular": { + "message": "$COUNT$ shared folder", + "placeholders": { + "count": { + "content": "$1", + "example": "1" + } + } + }, + "moreCollectionsShownAbove": { + "message": "$COUNT$ more collections shown above this button", + "description": "Announced to screen readers when a collapsed grid of collections is expanded. The revealed collections render above the button that expanded them.", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "moreCollectionsShownAboveSingular": { + "message": "1 more collection shown above this button", + "description": "Announced to screen readers when a collapsed grid of collections is expanded. The revealed collections render above the button that expanded them." + }, + "moreSharedFoldersShownAbove": { + "message": "$COUNT$ more shared folders shown above this button", + "description": "Announced to screen readers when a collapsed grid of shared folders is expanded. The revealed shared folders render above the button that expanded them.", + "placeholders": { + "count": { + "content": "$1", + "example": "3" + } + } + }, + "moreSharedFoldersShownAboveSingular": { + "message": "1 more shared folder shown above this button", + "description": "Announced to screen readers when a collapsed grid of shared folders is expanded. The revealed shared folders render above the button that expanded them." + }, "myFolder": { "message": "My folder" }, @@ -15341,6 +15425,9 @@ "providersubCanceledmessage": { "message": "To resubscribe, contact Bitwarden Customer Support." }, + "showAll": { + "message": "Show all" + }, "showMore": { "message": "Show more" }, diff --git a/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.html b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.html new file mode 100644 index 000000000000..ce8509cb237c --- /dev/null +++ b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.html @@ -0,0 +1,83 @@ +@let overflow = overflowCards(); + +@if (count() > 0) { +
+
+

+ {{ "collectionsInParent" | vfo1I18n: "sharedFoldersInParent" : parentName() }} +

+ @let countText = countSegments(); + {{ countText.before + }}{{ countText.count }}{{ countText.after }} +
+ + + + @if (overflow.length > 0) { + + + + + + } +
+} + + +
  • + + + + + {{ folder.name }} + + + +
  • +
    diff --git a/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.spec.ts b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.spec.ts new file mode 100644 index 000000000000..c5aef396282b --- /dev/null +++ b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.spec.ts @@ -0,0 +1,358 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { signal } from "@angular/core"; +import { ComponentFixture, TestBed } from "@angular/core/testing"; +import { NavigationExtras, provideRouter } from "@angular/router"; +import { mock, MockProxy } from "jest-mock-extended"; +import { BehaviorSubject } from "rxjs"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; + +import { RoutedVaultFilterModel } from "../../models/routed-vault-filter.model"; +import { RoutedVaultFilterService } from "../../services/routed-vault-filter.service"; +import { Vfo1TerminologyService } from "../../services/vfo1-terminology.service"; + +import { SharedFolderCardGridComponent } from "./shared-folder-card-grid.component"; + +/** Three columns × three rows render before the rest collapse. */ +const COLLAPSED_CARD_COUNT = 9; + +const TRIGGER_SELECTOR = "#shared-folder-card-grid_button_toggle-overflow"; + +describe("SharedFolderCardGridComponent", () => { + let fixture: ComponentFixture; + let liveAnnouncer: MockProxy; + + const filter$ = new BehaviorSubject({}); + const vfo1Enabled = signal(false); + const createRoute = jest.fn<[unknown[], NavigationExtras], [RoutedVaultFilterModel]>(); + + function folderNode(id: string, name: string): TreeNode { + const collection = new CollectionView({ + id: id as CollectionId, + organizationId: "org-1" as OrganizationId, + name, + }); + + return new TreeNode(collection, undefined as unknown as TreeNode); + } + + function folderNodes(count: number): TreeNode[] { + return Array.from({ length: count }, (_, i) => folderNode(`folder-${i}`, `Folder ${i}`)); + } + + function createComponent(folders: TreeNode[], parentName = "Engineering") { + fixture = TestBed.createComponent(SharedFolderCardGridComponent); + fixture.componentRef.setInput("folders", folders); + fixture.componentRef.setInput("parentName", parentName); + fixture.detectChanges(); + } + + function countLabel(): string | undefined { + return fixture.nativeElement.querySelector("section span")?.textContent?.trim(); + } + + function cardLinks(): HTMLAnchorElement[] { + return Array.from(fixture.nativeElement.querySelectorAll("a[bit-item-content]")); + } + + function trigger(): HTMLButtonElement | null { + return fixture.nativeElement.querySelector(TRIGGER_SELECTOR); + } + + beforeEach(async () => { + filter$.next({}); + vfo1Enabled.set(false); + liveAnnouncer = mock(); + + createRoute.mockReset(); + createRoute.mockImplementation((filter) => [ + ["/vault"], + { + queryParams: { sharedFolderId: filter.collectionId ?? null }, + queryParamsHandling: "merge", + state: { focusAfterNav: false }, + }, + ]); + + await TestBed.configureTestingModule({ + imports: [SharedFolderCardGridComponent], + providers: [ + provideRouter([]), + { + provide: I18nService, + useValue: { + // `I18nPipe` always forwards three positional params, so drop the unfilled ones. + t: (key: string, ...params: (string | number | undefined)[]) => { + const provided = params.filter((param) => param !== undefined); + return provided.length > 0 ? `${key}:${provided.join(",")}` : key; + }, + }, + }, + { provide: LiveAnnouncer, useValue: liveAnnouncer }, + { provide: RoutedVaultFilterService, useValue: { filter$, createRoute } }, + { + provide: Vfo1TerminologyService, + useValue: { + enabled: vfo1Enabled, + iconClass: (icon: string) => (vfo1Enabled() ? "bwi-shared-folder" : icon), + }, + }, + ], + }).compileComponents(); + }); + + describe("rendering child folders", () => { + it("renders each child as a card with a folder icon, name, and trailing chevron", () => { + createComponent(folderNodes(2)); + + const cards = cardLinks(); + expect(cards).toHaveLength(2); + expect(cards.map((card) => card.textContent?.trim())).toEqual(["Folder 0", "Folder 1"]); + + cards.forEach((card) => { + expect(card.querySelector("bit-icon-tile i")?.classList).toContain("bwi-collection-shared"); + expect(card.querySelector(".bwi-angle-right")).not.toBeNull(); + }); + }); + + it("borders each card on all four sides rather than as a stacked list row", () => { + createComponent(folderNodes(2)); + + // `bit-item` defaults to `tw-border-0 tw-border-b` for stacked rows; these cards stand alone + // and use the same border token as `BaseCardDirective`. + fixture.nativeElement.querySelectorAll("bit-item").forEach((item: HTMLElement) => { + expect(item.classList).toContain("!tw-border"); + expect(item.classList).toContain("!tw-border-border-base"); + }); + }); + + it("renders nothing when the parent passes an empty list", () => { + createComponent([]); + + expect(fixture.nativeElement.querySelector("section")).toBeNull(); + expect(cardLinks()).toHaveLength(0); + }); + + it("caps the grid at three columns, each at least 240px wide, with 12px spacing", () => { + createComponent(folderNodes(3)); + + const grid: HTMLElement = fixture.nativeElement.querySelector("ul"); + expect(grid.classList).toContain("tw-gap-3"); + expect(grid.style.gridTemplateColumns).toBe( + "repeat(auto-fill, minmax(min(100%, max(240px, (100% - 1.5rem) / 3)), 1fr))", + ); + }); + }); + + describe("card links", () => { + it("builds each href from RoutedVaultFilterService.createRoute for that child", () => { + createComponent(folderNodes(1)); + + expect(createRoute).toHaveBeenCalledWith( + expect.objectContaining({ collectionId: "folder-0" }), + ); + expect(cardLinks()[0].getAttribute("href")).toBe("/vault?sharedFolderId=folder-0"); + }); + + it("keeps the surrounding filter and clears the filters a collection cannot combine with", () => { + filter$.next({ + organizationId: "org-1" as OrganizationId, + organizationIdParamType: "query", + folderId: "folder-to-clear", + type: "login", + }); + + createComponent(folderNodes(1)); + + expect(createRoute).toHaveBeenCalledWith({ + organizationId: "org-1", + organizationIdParamType: "query", + collectionId: "folder-0", + folderId: undefined, + type: undefined, + }); + }); + + it("renders cards as anchors so click, Enter, and right/middle-click all navigate", () => { + createComponent(folderNodes(1)); + + const card = cardLinks()[0]; + expect(card.tagName).toBe("A"); + expect(card.getAttribute("href")).not.toBeNull(); + }); + }); + + describe("overflow rows", () => { + it("renders no trigger when the children fit in the first three rows", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT)); + + expect(cardLinks()).toHaveLength(COLLAPSED_CARD_COUNT); + expect(trigger()).toBeNull(); + }); + + it("collapses the overflow rows inside a disclosure controlled by the trigger", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 3)); + + const disclosure = fixture.nativeElement.querySelector("bit-disclosure"); + expect(disclosure.classList).toContain("tw-hidden"); + expect(disclosure.querySelectorAll("a[bit-item-content]")).toHaveLength(3); + expect(trigger()?.textContent?.trim()).toBe("showAll"); + }); + + it("reflects the open state on the trigger's aria-expanded", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 1)); + + expect(trigger()?.getAttribute("aria-expanded")).toBe("false"); + expect(trigger()?.getAttribute("aria-controls")).toBe( + fixture.nativeElement.querySelector("bit-disclosure").id, + ); + + trigger()?.click(); + fixture.detectChanges(); + + expect(trigger()?.getAttribute("aria-expanded")).toBe("true"); + expect(trigger()?.textContent?.trim()).toBe("showLess"); + expect(fixture.nativeElement.querySelector("bit-disclosure").classList).not.toContain( + "tw-hidden", + ); + }); + + it("flips the trigger's caret to match the open state", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 1)); + + expect(trigger()?.querySelector("i")?.classList).toContain("bwi-angle-down"); + + trigger()?.click(); + fixture.detectChanges(); + + expect(trigger()?.querySelector("i")?.classList).toContain("bwi-angle-up"); + }); + + it("re-collapses when the host navigates to a folder with different children", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 1)); + + trigger()?.click(); + fixture.detectChanges(); + expect(trigger()?.getAttribute("aria-expanded")).toBe("true"); + + fixture.componentRef.setInput("folders", folderNodes(COLLAPSED_CARD_COUNT + 2)); + fixture.detectChanges(); + + expect(trigger()?.getAttribute("aria-expanded")).toBe("false"); + }); + }); + + describe("announcing expansion", () => { + it("announces how many rows were revealed above the trigger", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 4)); + + trigger()?.click(); + fixture.detectChanges(); + + expect(liveAnnouncer.announce).toHaveBeenCalledWith("moreCollectionsShownAbove:4", "polite"); + }); + + it("announces shared folder terminology when the flag is on", () => { + vfo1Enabled.set(true); + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 4)); + + trigger()?.click(); + fixture.detectChanges(); + + expect(liveAnnouncer.announce).toHaveBeenCalledWith( + "moreSharedFoldersShownAbove:4", + "polite", + ); + }); + + it("does not announce on the initial collapsed render", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 4)); + + expect(liveAnnouncer.announce).not.toHaveBeenCalled(); + }); + }); + + describe("header", () => { + it("titles the section with the parent folder name", () => { + createComponent(folderNodes(2), "Engineering"); + + expect(fixture.nativeElement.querySelector("h2").textContent.trim()).toBe( + "collectionsInParent:Engineering", + ); + }); + + it("shows the child count alongside the title", () => { + createComponent(folderNodes(16)); + + expect(countLabel()).toBe("collectionCount:16"); + }); + + it("emphasizes the number without splitting the translated sentence", () => { + createComponent(folderNodes(16)); + + // The sentinel is substituted into the whole translated sentence, which is then split around + // it, so the count is bold while the surrounding words keep their translated order. + const emphasized = fixture.nativeElement.querySelector("section strong"); + expect(emphasized.textContent).toBe("16"); + expect(emphasized.classList).toContain("tw-font-bold"); + expect(countLabel()).toBe("collectionCount:16"); + expect(countLabel()).not.toContain("\uFFFC"); + }); + + it("counts every child, not just the rows on show", () => { + createComponent(folderNodes(COLLAPSED_CARD_COUNT + 7)); + + expect(cardLinks()).toHaveLength(COLLAPSED_CARD_COUNT + 7); + expect(countLabel()).toBe(`collectionCount:${COLLAPSED_CARD_COUNT + 7}`); + }); + }); + + describe("terminology", () => { + it("uses the legacy terms for the title and count when the flag is off", () => { + createComponent(folderNodes(2), "Engineering"); + + expect(fixture.nativeElement.querySelector("h2").textContent.trim()).toBe( + "collectionsInParent:Engineering", + ); + expect(countLabel()).toBe("collectionCount:2"); + }); + + it("uses shared folder terms for the title and count when the flag is on", () => { + vfo1Enabled.set(true); + createComponent(folderNodes(2), "Engineering"); + + expect(fixture.nativeElement.querySelector("h2").textContent.trim()).toBe( + "sharedFoldersInParent:Engineering", + ); + expect(countLabel()).toBe("sharedFolderCount:2"); + }); + + it("keeps the shared collection icon when the flag is off", () => { + createComponent(folderNodes(1)); + + expect(cardLinks()[0].querySelector("bit-icon-tile i")?.classList).toContain( + "bwi-collection-shared", + ); + }); + + it("swaps the folder icon when the flag is on", () => { + vfo1Enabled.set(true); + createComponent(folderNodes(1)); + + expect(cardLinks()[0].querySelector("bit-icon-tile i")?.classList).toContain( + "bwi-shared-folder", + ); + }); + + it("names the section with its heading", () => { + createComponent(folderNodes(1)); + + const section = fixture.nativeElement.querySelector("section"); + const heading = fixture.nativeElement.querySelector("h2"); + expect(section.getAttribute("aria-labelledby")).toBe(heading.id); + }); + }); +}); diff --git a/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.stories.ts b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.stories.ts new file mode 100644 index 000000000000..60ee39d5ecbb --- /dev/null +++ b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.stories.ts @@ -0,0 +1,194 @@ +import { signal } from "@angular/core"; +import { RouterTestingModule } from "@angular/router/testing"; +import { Meta, StoryObj, componentWrapperDecorator, moduleMetadata } from "@storybook/angular"; +import { BehaviorSubject } from "rxjs"; +import { getByRole, userEvent } from "storybook/test"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CollectionId, OrganizationId } from "@bitwarden/common/types/guid"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { BitwardenIcon, I18nMockService } from "@bitwarden/components"; + +import { RoutedVaultFilterModel } from "../../models/routed-vault-filter.model"; +import { RoutedVaultFilterService } from "../../services/routed-vault-filter.service"; +import { Vfo1TerminologyService } from "../../services/vfo1-terminology.service"; + +import { SharedFolderCardGridComponent } from "./shared-folder-card-grid.component"; + +function folderNode(name: string, id = name): TreeNode { + const collection = new CollectionView({ + id: id as CollectionId, + organizationId: "org-1" as OrganizationId, + name, + }); + + return new TreeNode(collection, undefined as unknown as TreeNode); +} + +function folderNodes(names: string[]): TreeNode[] { + return names.map((name, i) => folderNode(name, `folder-${i}`)); +} + +const DEFAULT_FOLDERS = folderNodes([ + "Engineering", + "Design", + "Marketing", + "Finance", + "People Ops", +]); + +/** Fourteen children — nine fill the first three rows, five collapse behind the trigger. */ +const MANY_FOLDERS = folderNodes([ + "Engineering", + "Design", + "Marketing", + "Finance", + "People Ops", + "Legal", + "Support", + "Sales", + "Security", + "Infrastructure", + "Data Platform", + "Mobile", + "Partnerships", + "Research", +]); + +const LONG_NAME_FOLDERS = folderNodes([ + "Engineering — Platform, Infrastructure, and Developer Experience", + "Design — Brand, Product, and Marketing Systems", + "A folder name with no spaces atallwhichcannotwrapanywhere", +]); + +/** + * Stories default to the flag on, since that is the state this component ships for; + * {@link TerminologyFlagOff} overrides it. `Vfo1TerminologyService.enabled` is a signal, so the mock + * exposes one too, and derives its icon mapping from it exactly as the real service does. + */ +function terminologyProvider(enabled: boolean) { + const enabledSignal = signal(enabled); + + return { + provide: Vfo1TerminologyService, + useValue: { + enabled: enabledSignal, + iconClass: (icon: BitwardenIcon): BitwardenIcon => + enabledSignal() ? "bwi-shared-folder" : icon, + }, + }; +} + +export default { + title: "Vault/Shared Folder Card Grid", + component: SharedFolderCardGridComponent, + decorators: [ + moduleMetadata({ + imports: [RouterTestingModule], + providers: [ + terminologyProvider(true), + { + provide: RoutedVaultFilterService, + useValue: { + filter$: new BehaviorSubject({}), + createRoute: (filter: RoutedVaultFilterModel) => [ + ["/vault"], + { + queryParams: { sharedFolderId: filter.collectionId ?? null }, + queryParamsHandling: "merge", + }, + ], + }, + }, + { + provide: I18nService, + useFactory: () => + new I18nMockService({ + collections: "Collections", + sharedFolders: "Shared folders", + collectionsInParent: (name) => `Collections in ${name}`, + sharedFoldersInParent: (name) => `Shared folders in ${name}`, + collectionCount: (count) => `${count} collections`, + collectionCountSingular: (count) => `${count} collection`, + sharedFolderCount: (count) => `${count} shared folders`, + sharedFolderSingular: (count) => `${count} shared folder`, + moreCollectionsShownAbove: (count) => + `${count} more collections shown above this button`, + moreCollectionsShownAboveSingular: `1 more collection shown above this button`, + moreSharedFoldersShownAbove: (count) => + `${count} more shared folders shown above this button`, + moreSharedFoldersShownAboveSingular: `1 more shared folder shown above this button`, + showAll: "Show all", + showLess: "Show less", + }), + }, + ], + }), + componentWrapperDecorator((story) => `
    ${story}
    `), + ], + args: { + folders: DEFAULT_FOLDERS, + parentName: "Departments", + }, +} as Meta; + +type Story = StoryObj; + +/** Five children — one full row of three plus a partial row, no overflow. */ +export const Default: Story = {}; + +export const SingleChild: Story = { + args: { + folders: folderNodes(["Engineering"]), + }, +}; + +/** Renders nothing at all, so the host needs no `@if` of its own. */ +export const NoChildren: Story = { + args: { + folders: [], + }, +}; + +export const ManyChildrenCollapsed: Story = { + args: { + folders: MANY_FOLDERS, + }, +}; + +export const ManyChildrenExpanded: Story = { + args: { + folders: MANY_FOLDERS, + }, + play: async (context) => { + const trigger = getByRole(context.canvasElement, "button", { name: "Show all" }); + await userEvent.click(trigger); + }, +}; + +/** Names truncate rather than blowing out the track width. */ +export const LongNames: Story = { + args: { + folders: LONG_NAME_FOLDERS, + }, +}; + +/** Every other story runs with the flag on; this one covers the pre-VFO1 terms and icon. */ +export const TerminologyFlagOff: Story = { + decorators: [ + moduleMetadata({ + providers: [terminologyProvider(false)], + }), + ], + args: { + folders: MANY_FOLDERS, + }, +}; + +export const Rtl: Story = { + decorators: [componentWrapperDecorator((story) => `
    ${story}
    `)], + args: { + folders: MANY_FOLDERS, + }, +}; diff --git a/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.ts b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.ts new file mode 100644 index 000000000000..8727286bbc58 --- /dev/null +++ b/libs/vault/src/components/shared-folder-card-grid/shared-folder-card-grid.component.ts @@ -0,0 +1,214 @@ +import { LiveAnnouncer } from "@angular/cdk/a11y"; +import { NgTemplateOutlet } from "@angular/common"; +import { + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + linkedSignal, + untracked, +} from "@angular/core"; +import { toSignal } from "@angular/core/rxjs-interop"; +import { Params, QueryParamsHandling, RouterLink } from "@angular/router"; + +import { CollectionView } from "@bitwarden/common/admin-console/models/collections"; +import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; +import { CollectionId } from "@bitwarden/common/types/guid"; +import { TreeNode } from "@bitwarden/common/vault/models/domain/tree-node"; +import { + DisclosureComponent, + DisclosureTriggerForDirective, + IconComponent, + IconTileComponent, + ItemModule, + LinkModule, + TypographyModule, +} from "@bitwarden/components"; +import { I18nPipe } from "@bitwarden/ui-common"; + +import { RoutedVaultFilterModel } from "../../models/routed-vault-filter.model"; +import { Vfo1I18nPipe } from "../../pipes/vfo1-i18n.pipe"; +import { Vfo1IconPipe } from "../../pipes/vfo1-icon.pipe"; +import { RoutedVaultFilterService } from "../../services/routed-vault-filter.service"; +import { Vfo1TerminologyService } from "../../services/vfo1-terminology.service"; + +/** The grid never grows past three columns, and three rows stay visible before the rest collapse. */ +const MAX_COLUMNS = 3; +const VISIBLE_ROWS = 3; +const COLLAPSED_CARD_COUNT = MAX_COLUMNS * VISIBLE_ROWS; + +/** + * Track sizing for the card grid, kept in sync with {@link MAX_COLUMNS} and the `tw-gap-3` (0.75rem) + * gap applied in the template. + * + * `auto-fill` wraps cards to whatever the container can hold, and the lower bound of the `minmax` + * caps the column count: a track can be no narrower than one third of the container (less the two + * gaps that sit between three columns), nor narrower than 240px. The outer `min(100%, …)` keeps a + * card from overflowing containers narrower than 240px. + */ +const GRID_TEMPLATE_COLUMNS = + "repeat(auto-fill, minmax(min(100%, max(240px, (100% - 1.5rem) / 3)), 1fr))"; + +// Sentinel substituted for the child count so a fully translated sentence can be split around it, +// letting the number be emphasized in the template without embedding markup in (or splitting up) +// the translated string. Mirrors the approach in `assign-collections.component.ts`. +const COUNT_TOKEN = "\uFFFC"; + +/** A single child folder, resolved to the route its card links to. */ +type SharedFolderCard = { + id: string; + name: string; + commands: unknown[]; + queryParams?: Params | null; + queryParamsHandling?: QueryParamsHandling | null; + state?: Record; +}; + +let nextId = 0; + +/** + * Renders the direct child folders of the shared folder currently in view as a responsive card + * grid. Each card is an anchor built from the host-provided {@link RoutedVaultFilterService}, so + * activation, middle-click, and right-click all behave like ordinary links. + * + * The component is presentational: it fetches nothing and has no loading state. Hosts pass the + * children they have already derived, and an empty list renders nothing. + */ +@Component({ + selector: "vault-shared-folder-card-grid", + templateUrl: "./shared-folder-card-grid.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + DisclosureComponent, + DisclosureTriggerForDirective, + I18nPipe, + IconComponent, + IconTileComponent, + ItemModule, + LinkModule, + NgTemplateOutlet, + RouterLink, + TypographyModule, + Vfo1I18nPipe, + Vfo1IconPipe, + ], +}) +export class SharedFolderCardGridComponent { + private readonly i18nService = inject(I18nService); + private readonly liveAnnouncer = inject(LiveAnnouncer); + private readonly routedVaultFilterService = inject(RoutedVaultFilterService); + private readonly vfo1TerminologyService = inject(Vfo1TerminologyService); + + /** + * Direct children of the shared folder in view, as already derived by the host. An empty array + * renders nothing. + */ + readonly folders = input.required[]>(); + + /** Name of the shared folder in view, used to title the section. */ + readonly parentName = input.required(); + + protected readonly headingId = `shared-folder-card-grid-heading-${nextId++}`; + protected readonly gridTemplateColumns = GRID_TEMPLATE_COLUMNS; + + /** Identifies the current set of children, so navigating between folders re-collapses the grid. */ + private readonly folderIds = computed(() => + this.folders() + .map((folder) => folder.node.id) + .join(","), + ); + + /** Two-way bound to the overflow disclosure so that expanding can be announced. */ + protected readonly expanded = linkedSignal({ + source: this.folderIds, + computation: () => false, + }); + + private readonly filter = toSignal(this.routedVaultFilterService.filter$, { + initialValue: {} as RoutedVaultFilterModel, + }); + + private readonly cards = computed(() => { + const filter = this.filter(); + + return this.folders().map((child) => { + // Mirrors how the vault filter selects a collection: keep the surrounding filter, swap in the + // child, and clear the filters that cannot apply alongside it. + const [commands, extras] = this.routedVaultFilterService.createRoute({ + ...filter, + collectionId: child.node.id as CollectionId, + folderId: undefined, + type: undefined, + }); + + return { + id: child.node.id, + name: child.node.name, + commands, + queryParams: extras?.queryParams, + queryParamsHandling: extras?.queryParamsHandling, + state: extras?.state, + }; + }); + }); + + protected readonly count = computed(() => this.cards().length); + + /** + * Breaks the child-count sentence into display segments so the number can be emphasized. A + * sentinel is substituted for the count and the fully translated sentence is split around it, so + * word order stays correct in every language and the count is always rendered as plain text + * rather than markup. + */ + protected readonly countSegments = computed(() => { + let sentence = ""; + if (this.count() === 1) { + sentence = this.i18nService.t( + this.vfo1TerminologyService.enabled() ? "sharedFolderSingular" : "collectionCountSingular", + COUNT_TOKEN, + ); + } else { + sentence = this.i18nService.t( + this.vfo1TerminologyService.enabled() ? "sharedFolderCount" : "collectionCount", + COUNT_TOKEN, + ); + } + + const [before, after = ""] = sentence.split(COUNT_TOKEN); + return { before, count: this.count(), after }; + }); + + protected readonly visibleCards = computed(() => this.cards().slice(0, COLLAPSED_CARD_COUNT)); + protected readonly overflowCards = computed(() => this.cards().slice(COLLAPSED_CARD_COUNT)); + + constructor() { + effect(() => { + if (!this.expanded()) { + return; + } + + // The disclosure renders above its own trigger, so the rows that just appeared are behind the + // user's focus and would otherwise go unnoticed by a screen reader. + const message = untracked(() => { + const overflowCardsCount = this.overflowCards().length; + if (overflowCardsCount === 1) { + return this.i18nService.t( + this.vfo1TerminologyService.enabled() + ? "moreSharedFoldersShownAboveSingular" + : "moreCollectionsShownAboveSingular", + ); + } + return this.i18nService.t( + this.vfo1TerminologyService.enabled() + ? "moreSharedFoldersShownAbove" + : "moreCollectionsShownAbove", + overflowCardsCount, + ); + }); + + void this.liveAnnouncer.announce(message, "polite"); + }); + } +} diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index 3cd99ee06d6d..c9eedd16f331 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -40,6 +40,7 @@ export * from "./components/add-item-dialog/add-item-dialog.component"; export * from "./components/carousel"; export * from "./components/new-cipher-menu/new-cipher-menu.component"; export * from "./components/permit-cipher-details-popover/permit-cipher-details-popover.component"; +export { SharedFolderCardGridComponent } from "./components/shared-folder-card-grid/shared-folder-card-grid.component"; export * from "./components/show-quick-copy-actions-details-popover/show-quick-copy-actions-details-popover.component"; export * from "./components/vault-items-transfer"; export { VaultItem, compareVaultItems } from "./components/vault-item";