From 4908683d1f00c18840c90df668eb2670e811cf6d Mon Sep 17 00:00:00 2001 From: Maria Garcia Luque Date: Thu, 23 Jul 2026 13:20:51 +0200 Subject: [PATCH 1/5] [FIR-295] Add AccordionContract for the ff-accordion pattern Defines the ff-accordion pattern contract (composes ff-panel and ff-icon only): sections/mode/expandedIds API, the toggle-button + role=region behavior clauses, and the roving-header keyboard model. Wires it into ALL_CONTRACTS (23 primitives + 7 patterns) and updates the structural inventory spec. --- packages/design-system-contract/src/index.ts | 8 ++- .../src/lib/patterns/accordion.contract.ts | 72 +++++++++++++++++++ .../lib/verify/verify-implementation.spec.ts | 6 +- 3 files changed, 82 insertions(+), 4 deletions(-) create mode 100644 packages/design-system-contract/src/lib/patterns/accordion.contract.ts 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 ` +
+
+ +
+
+ +} 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..9e293a8 --- /dev/null +++ b/packages/design-system/src/lib/patterns/ff-accordion/ff-accordion.component.scss @@ -0,0 +1,74 @@ +.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%; + 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 `