diff --git a/libs/components/src/accordion/accordion-group.component.ts b/libs/components/src/accordion/accordion-group.component.ts new file mode 100644 index 000000000000..83a2599c3626 --- /dev/null +++ b/libs/components/src/accordion/accordion-group.component.ts @@ -0,0 +1,21 @@ +import { booleanAttribute, ChangeDetectionStrategy, Component, input, signal } from "@angular/core"; + +import { AccordionVariant } from "./accordion.component"; + +@Component({ + selector: "bit-accordion-group", + template: "", + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class AccordionGroupComponent { + readonly singleSelect = input(false, { transform: booleanAttribute }); + readonly variant = input("default"); + + readonly activeAccordionId = signal(null); + + notifyOpened(id: string) { + if (this.singleSelect()) { + this.activeAccordionId.set(id); + } + } +} diff --git a/libs/components/src/accordion/accordion.component.html b/libs/components/src/accordion/accordion.component.html new file mode 100644 index 000000000000..e2540f7b5192 --- /dev/null +++ b/libs/components/src/accordion/accordion.component.html @@ -0,0 +1,36 @@ + +@if (open()) { +
+ +
+} diff --git a/libs/components/src/accordion/accordion.component.spec.ts b/libs/components/src/accordion/accordion.component.spec.ts new file mode 100644 index 000000000000..fb66b3cc5f73 --- /dev/null +++ b/libs/components/src/accordion/accordion.component.spec.ts @@ -0,0 +1,146 @@ +import { ComponentFixture, TestBed } from "@angular/core/testing"; + +import { AccordionComponent } from "./accordion.component"; + +describe("AccordionComponent", () => { + let component: AccordionComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [AccordionComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(AccordionComponent); + component = fixture.componentInstance; + fixture.componentRef.setInput("title", "Test Heading"); + fixture.detectChanges(); + }); + + it("creates", () => { + expect(component).toBeTruthy(); + }); + + describe("default state", () => { + it("is collapsed by default", () => { + expect(component.open()).toBe(false); + }); + + it("does not render content panel when collapsed", () => { + expect(fixture.nativeElement.querySelector(`#${component.contentId}`)).toBeNull(); + }); + + it("button has aria-expanded=false when closed", () => { + expect(fixture.nativeElement.querySelector("button").getAttribute("aria-expanded")).toBe( + "false", + ); + }); + + it("shows chevron-down icon when collapsed", () => { + expect(fixture.nativeElement.querySelector("bit-icon").classList).toContain("bwi-angle-down"); + }); + }); + + describe("toggle", () => { + it("opens when button is clicked", () => { + fixture.nativeElement.querySelector("button").click(); + fixture.detectChanges(); + expect(component.open()).toBe(true); + }); + + it("closes again on second click", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + fixture.nativeElement.querySelector("button").click(); + fixture.detectChanges(); + expect(component.open()).toBe(false); + }); + + it("renders content panel when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector(`#${component.contentId}`)).toBeTruthy(); + }); + + it("button has aria-expanded=true when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("button").getAttribute("aria-expanded")).toBe( + "true", + ); + }); + + it("shows chevron-up icon when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector("bit-icon").classList).toContain("bwi-angle-up"); + }); + }); + + describe("accessibility", () => { + it("button aria-controls matches content panel id when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + const btn = fixture.nativeElement.querySelector("button"); + const panel = fixture.nativeElement.querySelector(`#${component.contentId}`); + expect(btn.getAttribute("aria-controls")).toBe(panel.id); + }); + + it("content panel has role=region when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + expect( + fixture.nativeElement.querySelector(`#${component.contentId}`).getAttribute("role"), + ).toBe("region"); + }); + + it("content panel aria-labelledby points to trigger button id when open", () => { + fixture.componentRef.setInput("open", true); + fixture.detectChanges(); + expect( + fixture.nativeElement + .querySelector(`#${component.contentId}`) + .getAttribute("aria-labelledby"), + ).toBe(component.triggerId); + }); + + it("chevron icon has aria-hidden=true", () => { + expect(fixture.nativeElement.querySelector("bit-icon").getAttribute("aria-hidden")).toBe( + "true", + ); + }); + }); + + describe("disabled", () => { + beforeEach(() => { + fixture.componentRef.setInput("disabled", true); + fixture.detectChanges(); + }); + + it("does not toggle when clicked", () => { + fixture.nativeElement.querySelector("button").click(); + fixture.detectChanges(); + expect(component.open()).toBe(false); + }); + + it("button has disabled attribute", () => { + expect(fixture.nativeElement.querySelector("button").hasAttribute("disabled")).toBe(true); + }); + }); + + describe("subtitle", () => { + it("shows subtitle when provided", () => { + fixture.componentRef.setInput("subtitle", "My subtitle"); + fixture.detectChanges(); + const spans = fixture.nativeElement.querySelectorAll("button span"); + const found = Array.from(spans).some((el: any) => el.textContent.trim() === "My subtitle"); + expect(found).toBe(true); + }); + + it("does not render subtitle span when not provided", () => { + fixture.detectChanges(); + const spans = fixture.nativeElement.querySelectorAll("button span"); + expect(spans.length).toBe(1); + }); + }); +}); diff --git a/libs/components/src/accordion/accordion.component.ts b/libs/components/src/accordion/accordion.component.ts new file mode 100644 index 000000000000..f4c4f0fdf6bd --- /dev/null +++ b/libs/components/src/accordion/accordion.component.ts @@ -0,0 +1,148 @@ +import { + booleanAttribute, + ChangeDetectionStrategy, + Component, + computed, + effect, + inject, + input, + model, + untracked, +} from "@angular/core"; + +import { IconComponent } from "../icon"; +import { IconTileComponent } from "../icon-tile"; +import { BitwardenIcon } from "../shared/icon"; + +import { AccordionGroupComponent } from "./accordion-group.component"; + +export type AccordionSize = "sm" | "default"; + +export type AccordionVariant = "default" | "subtle"; + +let nextId = 0; + +@Component({ + selector: "bit-accordion", + templateUrl: "./accordion.component.html", + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [IconComponent, IconTileComponent], + host: { + "[class]": "hostClassList()", + }, +}) +export class AccordionComponent { + private readonly group = inject(AccordionGroupComponent, { optional: true }); + + constructor() { + effect(() => { + const activeId = this.group?.activeAccordionId(); + if (activeId != null && activeId !== this._baseId) { + untracked(() => this.open.set(false)); + } + }); + } + + readonly title = input.required(); + readonly subtitle = input(); + readonly open = model(false); + readonly startIcon = input(); + readonly disabled = input(false, { transform: booleanAttribute }); + readonly size = input("default"); + readonly variant = input("default"); + + protected readonly resolvedVariant = computed(() => this.group?.variant() ?? this.variant()); + + protected readonly _baseId = `bit-accordion-${nextId++}`; + readonly triggerId = `${this._baseId}-trigger`; + readonly contentId = `${this._baseId}-content`; + + protected toggle() { + if (!this.disabled()) { + this.open.update((o) => !o); + if (this.open()) { + this.group?.notifyOpened(this._baseId); + } + } + } + + protected readonly hostClassList = computed(() => + [ + "tw-block", + "tw-border", + "tw-border-solid", + "tw-border-border-base", + "tw-rounded-xl", + ...(this.group + ? [ + // Collapse inner radii and borders when stacked inside a group + "[&:not(:first-of-type)]:tw-rounded-t-none", + "[&:not(:last-of-type)]:tw-rounded-b-none", + "[&:not(:last-of-type)]:tw-border-b-0", + // Mirror those overrides onto the child button and content panel + "[&:not(:first-of-type)>[data-accordion-trigger]]:tw-rounded-t-none", + "[&:not(:last-of-type)>[data-accordion-trigger]]:tw-rounded-b-none", + "[&:not(:last-of-type)>[data-accordion-content]]:tw-rounded-b-none", + ] + : []), + ].join(" "), + ); + + protected readonly triggerClassList = computed(() => + [ + "tw-flex", + "tw-items-center", + "tw-gap-3", + "tw-w-full", + "tw-border-0", + "tw-text-start", + "tw-cursor-pointer", + "tw-transition-colors", + "tw-rounded-t-xl", + this.open() ? "" : "tw-rounded-b-xl", + this.resolvedVariant() === "default" ? "tw-bg-bg-secondary" : "tw-bg-bg-primary", + "enabled:hover:tw-bg-bg-hover", + "focus-visible:tw-outline-none", + "focus-visible:tw-ring-2", + "focus-visible:tw-ring-inset", + "focus-visible:tw-ring-border-focus", + "focus-visible:tw-border-border-focus", + "disabled:tw-cursor-not-allowed", + "disabled:tw-text-fg-inactive", + this.size() === "sm" ? "tw-p-3" : "tw-p-4", + ].join(" "), + ); + + protected readonly iconTileSize = computed(() => (this.size() === "sm" ? "base" : "lg")); + + protected readonly headingClassList = computed(() => + [ + "tw-font-medium", + "tw-leading-6", + this.size() === "sm" ? "tw-text-base" : "tw-text-lg", + this.disabled() ? "tw-text-fg-inactive" : "tw-text-fg-heading", + ].join(" "), + ); + + protected readonly subtitleClassList = computed(() => + ["tw-text-sm/5", this.disabled() ? "tw-text-fg-inactive" : "tw-text-fg-body"].join(" "), + ); + + protected readonly chevronClasses = computed(() => + [ + "tw-text-xl", + "tw-shrink-0", + this.disabled() ? "tw-text-fg-inactive" : "tw-text-fg-heading", + ].join(" "), + ); + + protected readonly contentClassList = computed(() => + [ + "tw-p-4", + "tw-rounded-b-xl", + this.resolvedVariant() === "subtle" + ? "tw-border-t tw-border-solid tw-border-border-base" + : "", + ].join(" "), + ); +} diff --git a/libs/components/src/accordion/accordion.mdx b/libs/components/src/accordion/accordion.mdx new file mode 100644 index 000000000000..5f46dc262184 --- /dev/null +++ b/libs/components/src/accordion/accordion.mdx @@ -0,0 +1,147 @@ +import { Meta, Canvas, Primary, Controls } from "@storybook/addon-docs/blocks"; + +import * as stories from "./accordion.stories"; + + + +```ts +import { AccordionComponent, AccordionGroupComponent } from "@bitwarden/components"; +``` + +# Accordion + +The accordion component is an expandable container used to progressively disclose content. It helps +reduce cognitive load by allowing users to show and hide sections of related information on demand. + +It is well suited for FAQs, settings panels, filters, and any content where users are unlikely to +need all sections at once. If all sections are likely to be relevant to the user, consider using a +flat layout instead to avoid hiding important content. + + + + +## Variants + +The `default` variant uses a secondary background for its header. Use it when the accordion appears +on `bg-primary`. + + + +The `subtle` variant uses a primary background for its header, with a border separating the header +from the content panel. Use it when the accordion appears on `bg-secondary` or `bg-tertiary`. + + + +## Sizes + +The `default` size is the standard accordion size. Prefer this variant when possible. Subtitles are +visible in this size. + + + +The `sm` size reduces padding and hides the subtitle. Use it when vertical space is constrained or +when multiple accordions appear in a dense list. If a heading and subtitle are both needed, use the +default size. + + + +## Header content + +### Start icon + +An icon tile can be placed before the heading using the `startIcon` input. + + + +### End slot + +Additional elements such as badges can be placed at the trailing end of the header row using the +`slot="end"` content projection attribute. + +```html + + 1 of 3 complete + + +``` + + + +## States + +### Disabled + +A disabled accordion cannot be opened or closed. The trigger and text render in the inactive color +to communicate that the row is not interactive. + + + +## Grouped accordions + +Wrap multiple `bit-accordion` elements in `bit-accordion-group` to form a visual group. The group +automatically collapses inner border radii and shared borders so the rows read as a single unit. + + + + + +### Single-select + +Add the `singleSelect` attribute to `bit-accordion-group` to enforce that only one row can be open +at a time. When a row is opened, any other open row in the group closes automatically. + +```html + + ... + ... + +``` + +Never use `singleSelect` on an accordion group that contains forms — users may need to reference +multiple sections simultaneously to complete a task. + + + +## Usage guidelines + +**Do:** + +- Use for FAQs, settings panels, filters, and other content where users are unlikely to need all + sections at once. +- Allow multiple rows to be open simultaneously (the default). Single-select behavior may be applied + as needed, but never on accordions that contain forms. + +**Do not:** + +- Nest an accordion inside another accordion. +- Leave the content slot empty when a row is open, unless showing an intentional empty state. +- Mix slot content types inconsistently within the same accordion group. + +## Content guidelines + +Accordion headings should be concise and descriptive. Aim for 2–5 words. Write them in sentence case +— for example, "Advanced settings" or "Import your passwords." Avoid vague headings such as "More +options" or "Other"; the heading should clearly communicate what the user will find when the section +is expanded. + +Subtitles are optional and should add context the heading alone cannot convey. Avoid restating the +heading. + +When stacking multiple accordions in a group, use parallel grammatical structure across all +headings. For example, do not mix noun phrases ("Advanced settings") with imperative phrases +("Configure your account"). + +Expanded content should be self-contained within its panel. Avoid requiring users to open multiple +accordions simultaneously to complete a task. If accordion content includes its own heading, it +should describe the panel content rather than repeat the accordion's heading. + +## Accessibility + +The accordion component automatically manages the relevant ARIA attributes: + +- The trigger button receives `aria-expanded` (true when open, false when closed) and + `aria-controls` pointing to the content region. +- The content region receives `aria-labelledby` pointing back to the trigger. + +Users can toggle the accordion with Enter or Space when the trigger is +focused. A visible focus ring is applied to the trigger on keyboard navigation. diff --git a/libs/components/src/accordion/accordion.stories.ts b/libs/components/src/accordion/accordion.stories.ts new file mode 100644 index 000000000000..fe017931fa6b --- /dev/null +++ b/libs/components/src/accordion/accordion.stories.ts @@ -0,0 +1,211 @@ +import { Meta, moduleMetadata, StoryObj } from "@storybook/angular"; + +import { formatArgsForCodeSnippet } from "../../../../.storybook/format-args-for-code-snippet"; +import { BadgeComponent } from "../badge"; +import { IconTileComponent } from "../icon-tile"; + +import { AccordionGroupComponent } from "./accordion-group.component"; +import { AccordionComponent } from "./accordion.component"; + +export default { + title: "Component Library/Accordion", + component: AccordionComponent, + decorators: [ + moduleMetadata({ + imports: [AccordionComponent, AccordionGroupComponent, IconTileComponent, BadgeComponent], + }), + ], + args: { + title: "Advanced settings", + subtitle: "Additional configurations for custom settings", + open: false, + disabled: false, + size: "default", + variant: "default", + }, + argTypes: { + size: { control: "select", options: ["default", "sm"] }, + variant: { control: "select", options: ["default", "subtle"] }, + }, + parameters: { + design: { + type: "figma", + url: "https://www.figma.com/design/Zt3YSeb6E6lebAffrNLa0h/branch/rKUVGKb7Kw3d6YGoQl6Ho7/Archive---Tailwind-Component-Library?node-id=42192-6301", + }, + }, +} as Meta; + +type Story = StoryObj; + +export const Default: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + (args)} + > + + Save time by importing data from another password manager. No data to import? + You can manually add items to your vault. + + + `, + }), +}; + +export const Subtle: Story = { + ...Default, + args: { variant: "subtle" }, +}; + +export const WithStartIcon: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + Content area with an icon tile in the header. + + `, + }), + args: { open: false }, +}; + +export const WithEndSlot: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + 1 of 3 complete + Content area with a badge in the end slot. + + `, + }), + args: { open: false }, +}; + +export const SmallSize: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + Small accordion content. + + `, + }), + args: { open: false }, +}; + +export const Inactive: Story = { + render: () => ({ + template: /*html*/ ` + + You cannot see this. + + `, + }), +}; + +export const DefaultExpanded: Story = { + render: () => ({ + template: /*html*/ ` + + This content is visible on load. + + `, + }), +}; + +export const SubtleExpanded: Story = { + render: () => ({ + template: /*html*/ ` + + This content is visible on load. + + `, + }), +}; + +export const Grouped: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + First accordion content. + + + Second accordion content. + + + Third accordion content. + + + `, + }), +}; + +export const SmallGrouped: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + First accordion content. + + + Second accordion content. + + + Third accordion content. + + + `, + }), +}; + +export const SingleSelect: Story = { + render: (args) => ({ + props: args, + template: /*html*/ ` + + + First accordion content. + + + Second accordion content. + + + Third accordion content. + + + `, + }), +}; diff --git a/libs/components/src/accordion/index.ts b/libs/components/src/accordion/index.ts new file mode 100644 index 000000000000..d28daec8d0b2 --- /dev/null +++ b/libs/components/src/accordion/index.ts @@ -0,0 +1,2 @@ +export { AccordionComponent, AccordionSize, AccordionVariant } from "./accordion.component"; +export { AccordionGroupComponent } from "./accordion-group.component"; diff --git a/libs/components/src/index.ts b/libs/components/src/index.ts index adb3d10f870a..1938c11ca390 100644 --- a/libs/components/src/index.ts +++ b/libs/components/src/index.ts @@ -1,6 +1,7 @@ export { ButtonType, ButtonLikeAbstraction } from "./shared/button-like.abstraction"; export { BitwardenIcon } from "./shared/icon"; export * from "./a11y"; +export * from "./accordion"; export * from "./anon-layout"; export * from "./async-actions"; export * from "./avatar"; diff --git a/libs/components/src/tw-theme.css b/libs/components/src/tw-theme.css index a65ce32e92eb..4b51c096e080 100644 --- a/libs/components/src/tw-theme.css +++ b/libs/components/src/tw-theme.css @@ -242,6 +242,7 @@ --color-fg-body: var(--color-gray-600); --color-fg-body-subtle: var(--color-gray-500); --color-fg-disabled: var(--color-gray-400); + --color-fg-inactive: var(--color-gray-400); /* Brand Foreground */ --color-fg-brand-soft: var(--color-brand-200); @@ -484,6 +485,7 @@ --color-fg-body: var(--color-gray-200); --color-fg-body-subtle: var(--color-gray-400); --color-fg-disabled: var(--color-gray-600); + --color-fg-inactive: var(--color-gray-600); /* Brand Foreground */ --color-fg-brand-soft: var(--color-brand-500); diff --git a/libs/components/tailwind.config.base.js b/libs/components/tailwind.config.base.js index 07e4aaff3303..b61b77a51144 100644 --- a/libs/components/tailwind.config.base.js +++ b/libs/components/tailwind.config.base.js @@ -171,6 +171,7 @@ module.exports = { body: "var(--color-fg-body)", "body-subtle": "var(--color-fg-body-subtle)", disabled: "var(--color-fg-disabled)", + inactive: "var(--color-fg-inactive)", "brand-soft": "var(--color-fg-brand-soft)", brand: "var(--color-fg-brand)", "brand-strong": "var(--color-fg-brand-strong)", @@ -271,6 +272,7 @@ module.exports = { "fg-body": "var(--color-fg-body)", "fg-body-subtle": "var(--color-fg-body-subtle)", "fg-disabled": "var(--color-fg-disabled)", + "fg-inactive": "var(--color-fg-inactive)", "fg-brand-soft": "var(--color-fg-brand-soft)", "fg-brand": "var(--color-fg-brand)", "fg-brand-strong": "var(--color-fg-brand-strong)",