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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion apps/playground/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { Route } from '@angular/router';
* - `/theming` — theming cascade + live token inspector
* - `/catalog` — one page per primitive (23) + ff-menu-button (pattern, kept here
* alongside its trigger primitive rather than under `/patterns`)
* - `/patterns` — composed patterns (ff-tab-bar, ff-data-table, ff-list) + backlog
* - `/patterns` — composed patterns (ff-tab-bar, ff-accordion, ff-data-table, ff-list) + backlog
*/
export const appRoutes: Route[] = [
{ path: '', pathMatch: 'full', redirectTo: 'catalog' },
Expand Down Expand Up @@ -161,6 +161,11 @@ export const appRoutes: Route[] = [
title: 'Tab Bar · Firefly DS',
loadComponent: () => import('./pages/patterns/tab-bar-page').then((m) => m.TabBarPage),
},
{
path: 'patterns/accordion',
title: 'Accordion · Firefly DS',
loadComponent: () => import('./pages/patterns/accordion-page').then((m) => m.AccordionPage),
},
{
path: 'patterns/data-table',
title: 'Data Table · Firefly DS',
Expand Down
126 changes: 126 additions & 0 deletions apps/playground/src/app/pages/patterns/accordion-page.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { ChangeDetectionStrategy, Component, signal } from '@angular/core';
import {
FfAccordionComponent,
FfAccordionSection,
FfAccordionSectionTemplateDirective,
} from '@fireflyframework/design-system';

import { DemoSection } from '../../shared/demo-section';

/** Pattern page for `ff-accordion`. */
@Component({
selector: 'app-accordion-page',
imports: [DemoSection, FfAccordionComponent, FfAccordionSectionTemplateDirective],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div class="page">
<h2 class="page__title">Accordion</h2>
<p class="page__lead">
<code>&lt;ff-accordion&gt;</code> — stacked disclosure sections composing
<code>ff-panel</code> and <code>ff-icon</code>. Fully controlled through
<code>expandedIds</code> / <code>(expandedIdsChange)</code>, just like
<code>ff-tab-bar</code>'s <code>activeId</code>.
</p>

<app-demo-section
heading="Single mode (default)"
description="Expanding a section closes whichever other section was open."
[code]="snippets.single"
>
<div class="demo-stack" style="max-width: 100%">
<ff-accordion [sections]="sections" [expandedIds]="singleExpanded()" (expandedIdsChange)="singleExpanded.set($event)">
<ng-template ffAccordionSection="shipping" let-section>
<p style="margin: 0">Ship to the address on file for {{ section.heading }}.</p>
</ng-template>
<ng-template ffAccordionSection="billing">
<p style="margin: 0">Invoices are sent to the billing email on record.</p>
</ng-template>
<ng-template ffAccordionSection="notes">
<p style="margin: 0">Internal notes are visible to the account team only.</p>
</ng-template>
</ff-accordion>
<span class="demo-label">expandedIds = "{{ singleExpanded().join(', ') || '(none)' }}"</span>
</div>
</app-demo-section>

<app-demo-section
heading="Multiple mode"
description="Any number of sections can stay expanded independently."
[code]="snippets.multiple"
>
<div class="demo-stack" style="max-width: 100%">
<ff-accordion
mode="multiple"
[sections]="sections"
[expandedIds]="multipleExpanded()"
(expandedIdsChange)="multipleExpanded.set($event)"
>
<ng-template ffAccordionSection="shipping" let-section>
<p style="margin: 0">Ship to the address on file for {{ section.heading }}.</p>
</ng-template>
<ng-template ffAccordionSection="billing">
<p style="margin: 0">Invoices are sent to the billing email on record.</p>
</ng-template>
<ng-template ffAccordionSection="notes">
<p style="margin: 0">Internal notes are visible to the account team only.</p>
</ng-template>
</ff-accordion>
<span class="demo-label">expandedIds = "{{ multipleExpanded().join(', ') || '(none)' }}"</span>
</div>
</app-demo-section>

<app-demo-section
heading="Disabled section"
description="A disabled section's toggle cannot be activated or reached by roving focus."
[code]="snippets.disabled"
>
<div class="demo-stack" style="max-width: 100%">
<ff-accordion
[sections]="sectionsWithDisabled"
[expandedIds]="disabledExpanded()"
(expandedIdsChange)="disabledExpanded.set($event)"
>
<ng-template ffAccordionSection="active" let-section>
<p style="margin: 0">{{ section.heading }} is open for editing.</p>
</ng-template>
<ng-template ffAccordionSection="locked">
<p style="margin: 0">This content is unreachable while the section is disabled.</p>
</ng-template>
</ff-accordion>
</div>
</app-demo-section>
</div>
`,
})
export class AccordionPage {
protected readonly sections: readonly FfAccordionSection[] = [
{ id: 'shipping', heading: 'Shipping address' },
{ id: 'billing', heading: 'Billing details' },
{ id: 'notes', heading: 'Notes' },
];

protected readonly sectionsWithDisabled: readonly FfAccordionSection[] = [
{ id: 'active', heading: 'Editable section' },
{ id: 'locked', heading: 'Locked section', disabled: true },
];

protected readonly singleExpanded = signal<readonly string[]>(['shipping']);
protected readonly multipleExpanded = signal<readonly string[]>(['shipping', 'billing']);
protected readonly disabledExpanded = signal<readonly string[]>([]);

protected readonly snippets = {
single: `<ff-accordion
[sections]="[
{ id: 'shipping', heading: 'Shipping address' },
{ id: 'billing', heading: 'Billing details' },
]"
[expandedIds]="expanded()"
(expandedIdsChange)="expanded.set($event)"
>
<ng-template ffAccordionSection="shipping" let-section>…</ng-template>
<ng-template ffAccordionSection="billing">…</ng-template>
</ff-accordion>`,
multiple: `<ff-accordion mode="multiple" [sections]="sections" [expandedIds]="expanded()" (expandedIdsChange)="expanded.set($event)">…</ff-accordion>`,
disabled: `<ff-accordion [sections]="[{ id: 'locked', heading: 'Locked section', disabled: true }]" …>…</ff-accordion>`,
};
}
2 changes: 1 addition & 1 deletion apps/playground/src/app/pages/patterns/patterns-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ const PATTERN_BACKLOG = [
<h2 class="page__title">Patterns</h2>
<p class="page__lead">
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.
</p>

<div class="catalog-grid">
Expand Down
1 change: 1 addition & 0 deletions apps/playground/src/app/shared/catalog-nav.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.' },
];
3 changes: 3 additions & 0 deletions packages/design-system-contract/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion packages/design-system-contract/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,13 +42,17 @@ 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';
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,
Expand Down Expand Up @@ -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';
Expand All @@ -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[] = [
Expand Down Expand Up @@ -136,6 +141,7 @@ export const ALL_CONTRACTS: readonly DsComponentContract[] = [
ToastContract,
TooltipContract,
// patterns
AccordionContract,
DialogContainerContract,
MenuButtonContract,
TabBarContract,
Expand Down
Original file line number Diff line number Diff line change
@@ -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 `<button>` 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 <button> 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',
],
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/design-system/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<button>` 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
Expand Down
9 changes: 9 additions & 0 deletions packages/design-system/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
@for (section of sections(); track section.id; let i = $index) {
<ff-panel class="ff-accordion__item">
<button
ff-panel-heading
type="button"
class="ff-accordion__toggle"
[id]="headerId(section.id)"
[attr.aria-expanded]="isExpanded(section.id)"
[attr.aria-controls]="regionId(section.id)"
[disabled]="section.disabled || null"
(click)="toggle(section)"
(keydown)="onKeydown($event)"
>
<span class="ff-accordion__heading">{{ section.heading }}</span>
<ff-icon
name="chevron-down"
size="sm"
class="ff-accordion__chevron"
[class.ff-accordion__chevron--expanded]="isExpanded(section.id)"
/>
</button>
<div class="ff-accordion__panel" [class.ff-accordion__panel--collapsed]="!isExpanded(section.id)">
<div
class="ff-accordion__region"
role="region"
[id]="regionId(section.id)"
[attr.aria-labelledby]="headerId(section.id)"
[attr.inert]="isExpanded(section.id) ? null : ''"
>
<ng-container
[ngTemplateOutlet]="sectionTemplateMap().get(section.id) ?? null"
[ngTemplateOutletContext]="{ $implicit: section, index: i }"
/>
</div>
</div>
</ff-panel>
}
Loading
Loading