Patterns are compositions of primitives (and only primitives) solving a recurring
- UI problem. Three patterns are available today; the rest are on the backlog.
+ UI problem. Four patterns are available today; the rest are on the backlog.
diff --git a/apps/playground/src/app/shared/catalog-nav.ts b/apps/playground/src/app/shared/catalog-nav.ts
index 2c788cd..f36043c 100644
--- a/apps/playground/src/app/shared/catalog-nav.ts
+++ b/apps/playground/src/app/shared/catalog-nav.ts
@@ -40,6 +40,7 @@ export const CATALOG_COMPONENTS: readonly CatalogEntry[] = [
/** Patterns demonstrated under `/patterns`. */
export const PATTERN_COMPONENTS: readonly CatalogEntry[] = [
{ slug: 'tab-bar', label: 'Tab Bar', selector: 'ff-tab-bar', description: 'Horizontal tab strip (underline/pills) composing ff-icon and ff-badge.' },
+ { slug: 'accordion', label: 'Accordion', selector: 'ff-accordion', description: 'Stacked disclosure sections (single/multiple) composing ff-panel and ff-icon.' },
{ slug: 'data-table', label: 'Data Table', selector: 'ff-data-table', description: 'Typed headers with cell/row/expansion templates, sorting, selection and server-side pagination.' },
{ slug: 'list', label: 'List', selector: 'ff-list', description: 'Item-per-template collection sharing the data table selection, pagination and empty states.' },
];
diff --git a/packages/design-system-contract/CHANGELOG.md b/packages/design-system-contract/CHANGELOG.md
index 30bf952..d5848e0 100644
--- a/packages/design-system-contract/CHANGELOG.md
+++ b/packages/design-system-contract/CHANGELOG.md
@@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+- `AccordionContract`: contract for the `ff-accordion` pattern (composes `ff-panel` and `ff-icon` only) — the aggregate now covers 23 primitives + 7 patterns; new `FfAccordionMode` and `FfAccordionSection` public types
+
### Changed
- `AvatarContract`: `size` type widened to accept a literal pixel number; new `name`, `round`, `cornerRadius` and `tone` inputs; the `aria` clause documents that the resolved initials (explicit `initials`, else derived from `name`) drive the aria-label fallback
diff --git a/packages/design-system-contract/src/index.ts b/packages/design-system-contract/src/index.ts
index 0c65fd5..72ce3a6 100644
--- a/packages/design-system-contract/src/index.ts
+++ b/packages/design-system-contract/src/index.ts
@@ -42,6 +42,7 @@ export { ToastContract } from './lib/primitives/toast.contract';
export { TooltipContract } from './lib/primitives/tooltip.contract';
// ---- Pattern contracts ----
+export { AccordionContract } from './lib/patterns/accordion.contract';
export { DialogContainerContract } from './lib/patterns/dialog-container.contract';
export { MenuButtonContract } from './lib/patterns/menu-button.contract';
export { TabBarContract } from './lib/patterns/tab-bar.contract';
@@ -49,6 +50,9 @@ export { ToastContainerContract } from './lib/patterns/toast-container.contract'
export { DataTableContract } from './lib/patterns/data-table.contract';
export { ListContract } from './lib/patterns/list.contract';
+// ---- Pattern-shared public types (accordion) ----
+export type { FfAccordionMode, FfAccordionSection } from './lib/patterns/accordion.contract';
+
// ---- Pattern-shared public types (data-table / list) ----
export type {
FfSortDirection,
@@ -99,6 +103,7 @@ import { SelectContract } from './lib/primitives/select.contract';
import { SkeletonContract } from './lib/primitives/skeleton.contract';
import { ToastContract } from './lib/primitives/toast.contract';
import { TooltipContract } from './lib/primitives/tooltip.contract';
+import { AccordionContract } from './lib/patterns/accordion.contract';
import { DialogContainerContract } from './lib/patterns/dialog-container.contract';
import { MenuButtonContract } from './lib/patterns/menu-button.contract';
import { TabBarContract } from './lib/patterns/tab-bar.contract';
@@ -107,7 +112,7 @@ import { DataTableContract } from './lib/patterns/data-table.contract';
import { ListContract } from './lib/patterns/list.contract';
/**
- * Every design-system component contract (23 primitives + 6 patterns),
+ * Every design-system component contract (23 primitives + 7 patterns),
* aggregated for whole-system checks such as {@link verifyDsContracts}.
*/
export const ALL_CONTRACTS: readonly DsComponentContract[] = [
@@ -136,6 +141,7 @@ export const ALL_CONTRACTS: readonly DsComponentContract[] = [
ToastContract,
TooltipContract,
// patterns
+ AccordionContract,
DialogContainerContract,
MenuButtonContract,
TabBarContract,
diff --git a/packages/design-system-contract/src/lib/patterns/accordion.contract.ts b/packages/design-system-contract/src/lib/patterns/accordion.contract.ts
new file mode 100644
index 0000000..d2f31e0
--- /dev/null
+++ b/packages/design-system-contract/src/lib/patterns/accordion.contract.ts
@@ -0,0 +1,72 @@
+import type { DsComponentContract } from '../contract.types';
+
+/**
+ * How many `ff-accordion` sections may be expanded at once: `'single'`
+ * expanding a section collapses whichever other section was open;
+ * `'multiple'` lets any number of sections stay expanded independently.
+ */
+export type FfAccordionMode = 'single' | 'multiple';
+
+/**
+ * Typed description of one `ff-accordion` section. `id` matches the section
+ * against its `[ffAccordionSection]` content template and against the
+ * `expandedIds` list that drives (and reports) expansion.
+ */
+export interface FfAccordionSection {
+ /** Stable section identifier, matched against `[ffAccordionSection]="id"` templates and `expandedIds`. */
+ readonly id: string;
+ /** Disclosure header text. */
+ readonly heading: string;
+ /** Disables the section's toggle: it cannot be expanded or collapsed by click or keyboard. */
+ readonly disabled?: boolean;
+}
+
+/**
+ * Contract of the `ff-accordion` pattern.
+ *
+ * Stacked disclosure sections, each rendered as an `ff-panel` whose header
+ * zone hosts the accessible toggle `
` and whose body zone hosts an
+ * animated `role="region"` wrapper around the section's projected content.
+ * `mode` governs whether one (`'single'`) or several (`'multiple'`) sections
+ * may be expanded at once; the component never owns which sections start
+ * expanded — that is the `expandedIds` input, updated by the consumer from
+ * `expandedIdsChange`. Composes `ff-panel` and `ff-icon` — primitives only
+ * (pattern tier).
+ */
+export const AccordionContract: DsComponentContract = {
+ selector: 'ff-accordion',
+ category: 'pattern',
+ composes: ['ff-panel', 'ff-icon'],
+ inputs: {
+ sections: {
+ type: 'readonly { id: string; heading: string; disabled?: boolean }[]',
+ required: true,
+ },
+ mode: {
+ type: "'single' | 'multiple'",
+ required: false,
+ default: "'single'",
+ },
+ expandedIds: { type: 'readonly string[]', required: false, default: '[]' },
+ },
+ outputs: {
+ expandedIdsChange: { type: 'readonly string[]' },
+ },
+ behavior: {
+ contentSlots: ['[ffAccordionSection]'],
+ hostAttributeOwnership: ['class'],
+ keyboard: [
+ 'ArrowDown moves focus to the next enabled section header (wrapping)',
+ 'ArrowUp moves focus to the previous enabled section header (wrapping)',
+ 'Home moves focus to the first enabled section header',
+ 'End moves focus to the last enabled section header',
+ 'Enter/Space toggle the focused section (native button semantics)',
+ ],
+ aria: [
+ 'each section header is a native with aria-expanded reflecting its expansion state and aria-controls pointing to its region',
+ 'each section body is role="region" with aria-labelledby pointing to its header',
+ 'a collapsed section region is inert: its content is unreachable by pointer, keyboard and assistive technology until expanded',
+ 'disabled sections are excluded from activation and roving focus',
+ ],
+ },
+};
diff --git a/packages/design-system-contract/src/lib/verify/verify-implementation.spec.ts b/packages/design-system-contract/src/lib/verify/verify-implementation.spec.ts
index 47c04bb..431b19b 100644
--- a/packages/design-system-contract/src/lib/verify/verify-implementation.spec.ts
+++ b/packages/design-system-contract/src/lib/verify/verify-implementation.spec.ts
@@ -14,10 +14,10 @@ describe('verifyDsContracts', () => {
expect(verifyDsContracts(ALL_CONTRACTS)).toEqual([]);
});
- it('covers the full inventory (23 primitives + 6 patterns)', () => {
- expect(ALL_CONTRACTS).toHaveLength(29);
+ it('covers the full inventory (23 primitives + 7 patterns)', () => {
+ expect(ALL_CONTRACTS).toHaveLength(30);
expect(ALL_CONTRACTS.filter((c) => c.category === 'primitive')).toHaveLength(23);
- expect(ALL_CONTRACTS.filter((c) => c.category === 'pattern')).toHaveLength(6);
+ expect(ALL_CONTRACTS.filter((c) => c.category === 'pattern')).toHaveLength(7);
});
it('flags a primitive that declares composes', () => {
diff --git a/packages/design-system/CHANGELOG.md b/packages/design-system/CHANGELOG.md
index 2241e2b..19ec084 100644
--- a/packages/design-system/CHANGELOG.md
+++ b/packages/design-system/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
+- `ff-accordion` pattern (composes `ff-panel` and `ff-icon` only): stacked disclosure sections in `single` (default) or `multiple` expansion mode, fully controlled through `expandedIds`/`expandedIdsChange`; each section header is a native `` with `aria-expanded`/`aria-controls`, its body a `role="region"` with `aria-labelledby` that goes `inert` while collapsed; the collapse height animates via a CSS grid track transition that is skipped under `prefers-reduced-motion: reduce`; new `FfAccordionSectionTemplateDirective` (`[ffAccordionSection]`), `FfAccordionMode`, `FfAccordionSection` and `FfAccordionSectionTemplateContext` types
- `ff-avatar`: `size` now also accepts a literal pixel number (proportional initials font size); `name` derives initials automatically (first + last word, uppercased) when the explicit `initials` input is unset; `round`/`cornerRadius` for a square shape with a custom corner radius; `tone` decorative background palette mirroring `ff-badge`'s `color` axis; new `FfAvatarTone` type
## [0.4.0] - 2026-07-21
diff --git a/packages/design-system/src/index.ts b/packages/design-system/src/index.ts
index b77e0f0..41355fc 100644
--- a/packages/design-system/src/index.ts
+++ b/packages/design-system/src/index.ts
@@ -71,6 +71,15 @@ export type { FfSkeletonVariant } from './lib/primitives/ff-skeleton';
export { FfEmptyStateComponent } from './lib/primitives/ff-empty-state';
// Patterns
+export {
+ FfAccordionComponent,
+ FfAccordionSectionTemplateDirective,
+} from './lib/patterns/ff-accordion';
+export type {
+ FfAccordionMode,
+ FfAccordionSection,
+ FfAccordionSectionTemplateContext,
+} from './lib/patterns/ff-accordion';
export { FfMenuButtonComponent } from './lib/patterns/ff-menu-button';
export type { FfMenuButtonItem } from './lib/patterns/ff-menu-button';
export { FfTabBarComponent } from './lib/patterns/ff-tab-bar';
diff --git a/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.html b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.html
new file mode 100644
index 0000000..ae1d2af
--- /dev/null
+++ b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.html
@@ -0,0 +1,37 @@
+@for (section of sections(); track section.id; let i = $index) {
+
+
+ {{ section.heading }}
+
+
+
+
+}
diff --git a/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.scss b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.scss
new file mode 100644
index 0000000..bf640b6
--- /dev/null
+++ b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.scss
@@ -0,0 +1,76 @@
+.ff-accordion {
+ // Component tokens are consumed with a fallback, never declared here: a
+ // declaration on this same selector would always beat a value inherited
+ // from an ancestor, so a container could never retint/resize an accordion.
+ display: flex;
+ flex-direction: column;
+ gap: var(--ff-accordion-gap, var(--ff-spacing-sm));
+
+ &__toggle {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--ff-spacing-sm);
+ width: 100%;
+ /* Minimum tap-target height for the disclosure control. */
+ min-height: var(--ff-accordion-toggle-min-height, 44px);
+ border: none;
+ background: none;
+ padding: 0;
+ cursor: pointer;
+ font-family: var(--ff-font-family);
+ text-align: start;
+
+ &:disabled {
+ cursor: not-allowed;
+ color: var(--ff-text-disabled, var(--ff-color-neutral-400));
+ }
+
+ &:focus-visible {
+ outline: 2px solid var(--ff-color-border-focus, var(--ff-color-primary-500));
+ outline-offset: 2px;
+ }
+ }
+
+ &__heading {
+ font-size: var(--ff-font-size-md);
+ font-weight: var(--ff-font-weight-semibold);
+ color: var(--ff-text-primary);
+ }
+
+ &__chevron {
+ flex: none;
+ transition: transform var(--ff-accordion-transition-duration, 150ms) ease;
+
+ &--expanded {
+ transform: rotate(180deg);
+ }
+ }
+
+ // Height animation: a CSS-only grid track transition (0fr collapsed, 1fr
+ // expanded) avoids measuring pixel heights in script. `overflow: hidden`
+ // on the grid item clips its content as the track shrinks.
+ &__panel {
+ display: grid;
+ grid-template-rows: 1fr;
+ transition: grid-template-rows var(--ff-accordion-transition-duration, 150ms) ease;
+
+ &--collapsed {
+ grid-template-rows: 0fr;
+ }
+ }
+
+ &__region {
+ overflow: hidden;
+ min-height: 0;
+ }
+
+ // Respect user motion preferences: the sections still expand/collapse,
+ // just without the animated transition.
+ @media (prefers-reduced-motion: reduce) {
+ .ff-accordion__panel,
+ .ff-accordion__chevron {
+ transition: none;
+ }
+ }
+}
diff --git a/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.spec.ts b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.spec.ts
new file mode 100644
index 0000000..24a2c05
--- /dev/null
+++ b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.spec.ts
@@ -0,0 +1,205 @@
+import 'zone.js';
+import 'zone.js/testing';
+import { Component } from '@angular/core';
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ BrowserTestingModule,
+ platformBrowserTesting,
+} from '@angular/platform-browser/testing';
+import {
+ FfAccordionComponent,
+ FfAccordionSectionTemplateDirective,
+ FfAccordionSection,
+ FfAccordionMode,
+} from './ff-accordion.component';
+import { provideFfIcons } from '../../primitives/ff-icon';
+
+TestBed.initTestEnvironment(BrowserTestingModule, platformBrowserTesting(), {
+ teardown: { destroyAfterEach: true },
+});
+
+const SECTIONS: readonly FfAccordionSection[] = [
+ { id: 'shipping', heading: 'Shipping address' },
+ { id: 'billing', heading: 'Billing details' },
+ { id: 'notes', heading: 'Notes', disabled: true },
+];
+
+describe('FfAccordionComponent', () => {
+ let fixture: ComponentFixture;
+ let component: FfAccordionComponent;
+
+ function setup(
+ inputs: Partial<{
+ sections: readonly FfAccordionSection[];
+ mode: FfAccordionMode;
+ expandedIds: readonly string[];
+ }> = {},
+ ) {
+ TestBed.configureTestingModule({
+ imports: [FfAccordionComponent],
+ providers: [provideFfIcons({ 'chevron-down': 'M0 0h24v24H0z' })],
+ });
+ fixture = TestBed.createComponent(FfAccordionComponent);
+ component = fixture.componentInstance;
+ fixture.componentRef.setInput('sections', inputs.sections ?? SECTIONS);
+ if (inputs.mode) fixture.componentRef.setInput('mode', inputs.mode);
+ if (inputs.expandedIds) fixture.componentRef.setInput('expandedIds', inputs.expandedIds);
+ fixture.detectChanges();
+ return fixture;
+ }
+
+ function toggles(): HTMLButtonElement[] {
+ return Array.from(fixture.nativeElement.querySelectorAll('.ff-accordion__toggle'));
+ }
+
+ function regions(): HTMLElement[] {
+ return Array.from(fixture.nativeElement.querySelectorAll('.ff-accordion__region'));
+ }
+
+ it('renders one toggle button and one role=region per section', () => {
+ setup();
+ const buttons = toggles();
+ expect(buttons.length).toBe(3);
+
+ const regionEls = regions();
+ expect(regionEls.length).toBe(3);
+ expect(regionEls.every((r) => r.getAttribute('role') === 'region')).toBe(true);
+ });
+
+ it('pairs each header/region via aria-controls / aria-labelledby / matching ids', () => {
+ setup();
+ const button = toggles()[0];
+ const region = regions()[0];
+ expect(button.getAttribute('aria-controls')).toBe(region.id);
+ expect(region.getAttribute('aria-labelledby')).toBe(button.id);
+ });
+
+ it('reflects the collapsed state via aria-expanded=false and marks the region inert', () => {
+ setup();
+ const button = toggles()[0];
+ const region = regions()[0];
+ expect(button.getAttribute('aria-expanded')).toBe('false');
+ expect(region.hasAttribute('inert')).toBe(true);
+ });
+
+ it('reflects the expanded state via aria-expanded=true and clears inert', () => {
+ setup({ expandedIds: ['shipping'] });
+ const button = toggles()[0];
+ const region = regions()[0];
+ expect(button.getAttribute('aria-expanded')).toBe('true');
+ expect(region.hasAttribute('inert')).toBe(false);
+ });
+
+ it('single mode: expanding a section emits only that section id, closing others', () => {
+ setup({ expandedIds: ['shipping'] });
+ const emitted: (readonly string[])[] = [];
+ component.expandedIdsChange.subscribe((next) => emitted.push(next));
+
+ toggles()[1].click();
+ expect(emitted).toEqual([['billing']]);
+ });
+
+ it('single mode: clicking the already-expanded section collapses it (emits [])', () => {
+ setup({ expandedIds: ['shipping'] });
+ const emitted: (readonly string[])[] = [];
+ component.expandedIdsChange.subscribe((next) => emitted.push(next));
+
+ toggles()[0].click();
+ expect(emitted).toEqual([[]]);
+ });
+
+ it('multiple mode: expanding a section adds to the current set instead of replacing it', () => {
+ setup({ mode: 'multiple', expandedIds: ['shipping'] });
+ const emitted: (readonly string[])[] = [];
+ component.expandedIdsChange.subscribe((next) => emitted.push(next));
+
+ toggles()[1].click();
+ expect(emitted).toEqual([['shipping', 'billing']]);
+ });
+
+ it('multiple mode: collapsing one expanded section leaves the others open', () => {
+ setup({ mode: 'multiple', expandedIds: ['shipping', 'billing'] });
+ const emitted: (readonly string[])[] = [];
+ component.expandedIdsChange.subscribe((next) => emitted.push(next));
+
+ toggles()[0].click();
+ expect(emitted).toEqual([['billing']]);
+ });
+
+ it('a disabled section never toggles (native disabled button swallows the click)', () => {
+ setup();
+ const emitted: (readonly string[])[] = [];
+ component.expandedIdsChange.subscribe((next) => emitted.push(next));
+
+ expect(toggles()[2].disabled).toBe(true);
+ toggles()[2].click();
+ expect(emitted).toEqual([]);
+ });
+
+ it('ArrowDown/ArrowUp move focus between enabled headers, wrapping and skipping disabled ones', () => {
+ setup();
+ const buttons = toggles();
+ buttons[0].focus();
+
+ buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[1]);
+
+ // 'notes' (index 2) is disabled and excluded from the query, so ArrowDown wraps back to 0.
+ buttons[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[0]);
+
+ buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[1]);
+ });
+
+ it('Home/End move focus to the first/last enabled header', () => {
+ setup();
+ const buttons = toggles();
+ buttons[1].focus();
+
+ buttons[1].dispatchEvent(new KeyboardEvent('keydown', { key: 'Home', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[0]);
+
+ buttons[0].dispatchEvent(new KeyboardEvent('keydown', { key: 'End', bubbles: true }));
+ expect(document.activeElement).toBe(buttons[1]);
+ });
+});
+
+@Component({
+ standalone: true,
+ imports: [FfAccordionComponent, FfAccordionSectionTemplateDirective],
+ template: `
+
+ Shipping body for {{ section.heading }}
+ Billing body for {{ section.heading }}
+
+ `,
+})
+class TestHostComponent {
+ readonly sections: readonly FfAccordionSection[] = SECTIONS;
+}
+
+describe('FfAccordionComponent (with projected section templates)', () => {
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [TestHostComponent],
+ providers: [provideFfIcons({ 'chevron-down': 'M0 0h24v24H0z' })],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(TestHostComponent);
+ fixture.detectChanges();
+ });
+
+ it('renders the matching template inside the expanded region and passes the section as context', () => {
+ const region = fixture.nativeElement.querySelector('.ff-accordion__region');
+ expect(region.textContent).toContain('Shipping body for Shipping address');
+ });
+
+ it('renders nothing for a section with no matching template', () => {
+ const regions = fixture.nativeElement.querySelectorAll('.ff-accordion__region');
+ // 'notes' has no [ffAccordionSection] template projected — its region stays empty.
+ expect(regions[2].textContent?.trim()).toBe('');
+ });
+});
diff --git a/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.ts b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.ts
new file mode 100644
index 0000000..0348eec
--- /dev/null
+++ b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.ts
@@ -0,0 +1,207 @@
+import { NgTemplateOutlet } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ Directive,
+ ElementRef,
+ TemplateRef,
+ ViewEncapsulation,
+ computed,
+ contentChildren,
+ inject,
+ input,
+ output,
+} from '@angular/core';
+import type { FfAccordionMode, FfAccordionSection } from '@fireflyframework/design-system-contract';
+
+import { FfIconComponent } from '../../primitives/ff-icon';
+import { FfPanelComponent } from '../../primitives/ff-panel';
+
+export type { FfAccordionMode, FfAccordionSection } from '@fireflyframework/design-system-contract';
+
+/**
+ * Template context handed to `[ffAccordionSection]` ``s:
+ * `$implicit` is the section descriptor, `index` its zero-based position.
+ */
+export interface FfAccordionSectionTemplateContext {
+ $implicit: FfAccordionSection;
+ index: number;
+}
+
+/**
+ * Marks an `` projected into `ff-accordion` as the body content
+ * of the section whose {@link FfAccordionSection.id} matches this
+ * directive's value.
+ *
+ * @example
+ * ```html
+ *
+ *
+ * Billing details for {{ section.heading }}…
+ *
+ *
+ * ```
+ */
+@Directive({ selector: '[ffAccordionSection]', standalone: true })
+export class FfAccordionSectionTemplateDirective {
+ /** Section id this template renders, matched against `FfAccordionSection.id`. */
+ readonly ffAccordionSection = input.required();
+
+ /** Template reference captured by `ff-accordion` and rendered per matching section via `NgTemplateOutlet`. */
+ readonly templateRef = inject>(TemplateRef);
+}
+
+/**
+ * Firefly accordion pattern.
+ *
+ * Stacked disclosure sections (`FfAccordionSection[]`), each rendered as an
+ * `ff-panel` whose header zone hosts the accessible toggle ``
+ * (`aria-expanded`, `aria-controls`) and whose body zone hosts an animated
+ * `role="region"` wrapper (`aria-labelledby`) around the section's
+ * `[ffAccordionSection]` template. `mode` governs whether one (`'single'`,
+ * default) or several (`'multiple'`) sections may stay expanded at once. The
+ * component does not own which sections start expanded — it is fully
+ * controlled through `expandedIds` / `expandedIdsChange`, mirroring
+ * `ff-tab-bar`'s `activeId` / `activeIdChange`. The collapse animates the
+ * region's height via a CSS grid track transition, skipped entirely under
+ * `prefers-reduced-motion: reduce`; a collapsed region is also marked
+ * `inert`, removing it from focus and the accessibility tree until expanded.
+ * Composes `ff-panel` and `ff-icon` — primitives only (pattern tier).
+ *
+ * @example
+ * ```html
+ *
+ * …
+ * …
+ *
+ * ```
+ */
+@Component({
+ selector: 'ff-accordion',
+ standalone: true,
+ imports: [NgTemplateOutlet, FfPanelComponent, FfIconComponent],
+ templateUrl: './ff-accordion.component.html',
+ styleUrl: './ff-accordion.component.scss',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ host: {
+ class: 'ff-accordion',
+ },
+})
+export class FfAccordionComponent {
+ /** @internal Sequence used to build unique header/region ids per instance. */
+ private static instanceCount = 0;
+
+ /** @internal Unique id prefix for this instance's header/region pairs. */
+ private readonly instanceId = `ff-accordion-${FfAccordionComponent.instanceCount++}`;
+
+ private readonly host = inject>(ElementRef);
+
+ /** Sections to render, in order. */
+ readonly sections = input.required();
+
+ /** Expansion mode: `'single'` (default, closes any other section) | `'multiple'`. */
+ readonly mode = input('single');
+
+ /** Ids of the currently expanded sections. */
+ readonly expandedIds = input([]);
+
+ /** Emits the next expanded-ids set when the user toggles a section. */
+ readonly expandedIdsChange = output();
+
+ /** Custom per-section body renderers, keyed by `FfAccordionSection.id`. */
+ protected readonly sectionTemplates = contentChildren(FfAccordionSectionTemplateDirective);
+
+ /** @internal Section templates indexed by id for O(1) lookup while rendering. */
+ protected readonly sectionTemplateMap = computed(() => {
+ const map = new Map>();
+ for (const directive of this.sectionTemplates()) {
+ map.set(directive.ffAccordionSection(), directive.templateRef);
+ }
+ return map;
+ });
+
+ /** @internal Whether the given section is currently expanded. */
+ protected isExpanded(id: string): boolean {
+ return this.expandedIds().includes(id);
+ }
+
+ /** @internal Id of a section's disclosure header button, referenced by its region's `aria-labelledby`. */
+ protected headerId(id: string): string {
+ return `${this.instanceId}-header-${id}`;
+ }
+
+ /** @internal Id of a section's region, referenced by its header's `aria-controls`. */
+ protected regionId(id: string): string {
+ return `${this.instanceId}-region-${id}`;
+ }
+
+ /**
+ * @internal Toggles a section's expansion and emits the resulting
+ * `expandedIds`. In `'single'` mode expanding one section always closes
+ * every other section; in `'multiple'` mode sections toggle independently.
+ * No-ops for disabled sections.
+ */
+ protected toggle(section: FfAccordionSection): void {
+ if (section.disabled) {
+ return;
+ }
+ const current = this.expandedIds();
+ const isOpen = current.includes(section.id);
+ const next =
+ this.mode() === 'single'
+ ? isOpen
+ ? []
+ : [section.id]
+ : isOpen
+ ? current.filter((id) => id !== section.id)
+ : [...current, section.id];
+ this.expandedIdsChange.emit(next);
+ }
+
+ /**
+ * @internal Roving focus across section headers: ArrowDown/ArrowUp move to
+ * the next/previous enabled header (wrapping), Home/End jump to the
+ * first/last enabled header.
+ */
+ protected onKeydown(event: KeyboardEvent): void {
+ const keys = ['ArrowDown', 'ArrowUp', 'Home', 'End'];
+ if (!keys.includes(event.key)) {
+ return;
+ }
+ event.preventDefault();
+
+ const buttons = Array.from(
+ this.host.nativeElement.querySelectorAll(
+ '.ff-accordion__toggle:not(:disabled)',
+ ),
+ );
+ if (buttons.length === 0) {
+ return;
+ }
+
+ const current = buttons.indexOf(event.target as HTMLButtonElement);
+ let next: number;
+ switch (event.key) {
+ case 'ArrowDown':
+ next = current < 0 ? 0 : (current + 1) % buttons.length;
+ break;
+ case 'ArrowUp':
+ next = current < 0 ? 0 : (current - 1 + buttons.length) % buttons.length;
+ break;
+ case 'Home':
+ next = 0;
+ break;
+ default:
+ next = buttons.length - 1;
+ }
+ buttons[next].focus();
+ }
+}
diff --git a/packages/design-system/src/lib/patterns/ff-accordion/index.ts b/packages/design-system/src/lib/patterns/ff-accordion/index.ts
new file mode 100644
index 0000000..91e2627
--- /dev/null
+++ b/packages/design-system/src/lib/patterns/ff-accordion/index.ts
@@ -0,0 +1,6 @@
+export { FfAccordionComponent, FfAccordionSectionTemplateDirective } from './ff-accordion.component';
+export type {
+ FfAccordionMode,
+ FfAccordionSection,
+ FfAccordionSectionTemplateContext,
+} from './ff-accordion.component';