Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,31 @@ Instead of simple strings, you can use content projection for the title and desc
```

<Canvas of={ExpandableCardStories.ContentProjection} />


## Accessible header without visible text

When neither title nor description is visible, provide `headerAriaLabel` so keyboard and screen-reader users can identify the header. This example uses the demo application's existing English translation.

<Canvas of={ExpandableCardStories.WithoutVisibleHeader} />

## Title-only and description-only layouts

A single header slot can use the available width. Projected content uses the application's `flex-grow-1` utility; string inputs demonstrate the equivalent title-only and description-only layouts.

<Canvas of={ExpandableCardStories.ProjectedTitleOnly} />
<Canvas of={ExpandableCardStories.ProjectedDescriptionOnly} />
<Canvas of={ExpandableCardStories.TitleOnly} />
<Canvas of={ExpandableCardStories.DescriptionOnly} />

## Custom header heights

Set `--ppw-expandable-card-header-height-collapsed` and `--ppw-expandable-card-header-height-expanded` on an individual card. This example matches the app: 48px collapsed and 64px expanded.

<Canvas of={ExpandableCardStories.CustomHeaderHeights} />

## Reading expansion state

Use a template reference to read the card's `panelOpenState`. The projected description below updates when the panel opens or closes; `openAsExpanded` supplies its initial state.

<Canvas of={ExpandableCardStories.ExpansionState} />
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import { provideHttpClient } from '@angular/common/http'
import { provideRouter, withDisabledInitialNavigation } from '@angular/router'
import { provideTranslateService, TranslateModule } from '@ngx-translate/core'
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader'
import { applicationConfig, Meta, moduleMetadata, StoryObj } from '@storybook/angular-vite'
import { ExpandableCardComponent } from './expandable-card.component'
import { provideNoopAnimations } from '@angular/platform-browser/animations'
Expand All @@ -8,13 +12,28 @@ const meta: Meta<ExpandableCardComponent> = {
component: ExpandableCardComponent,
decorators: [
moduleMetadata({
imports: [ExpandableCardComponent, MatExpansionModule]
imports: [ExpandableCardComponent, MatExpansionModule, TranslateModule]
}),
applicationConfig({
providers: [provideNoopAnimations()]
providers: [
provideNoopAnimations(),
provideRouter([], withDisabledInitialNavigation()),
provideHttpClient(),
provideTranslateService({
lang: 'en',
fallbackLang: 'en',
loader: provideTranslateHttpLoader({ prefix: '/assets/i18n/', suffix: '.json' })
})
]
})
],
args: { headerAriaLabel: undefined },
argTypes: {
headerAriaLabel: {
description: 'Accessible name for the header, especially when it has no visible title or description.',
control: 'text',
table: { category: 'Inputs' }
},
cardTitle: {
description: 'The title displayed in the card header.',
control: 'text',
Expand Down Expand Up @@ -52,6 +71,7 @@ export const Default: Story = {
props: args,
template: `
<ppw-expandable-card
[headerAriaLabel]="headerAriaLabel"
[cardTitle]="cardTitle"
[cardDescription]="cardDescription"
[openAsExpanded]="openAsExpanded"
Expand All @@ -73,6 +93,7 @@ export const InitiallyCollapsed: Story = {
props: args,
template: `
<ppw-expandable-card
[headerAriaLabel]="headerAriaLabel"
[cardTitle]="cardTitle"
[cardDescription]="cardDescription"
[openAsExpanded]="openAsExpanded"
Expand All @@ -94,6 +115,7 @@ export const NotCollapsible: Story = {
props: args,
template: `
<ppw-expandable-card
[headerAriaLabel]="headerAriaLabel"
[cardTitle]="cardTitle"
[cardDescription]="cardDescription"
[openAsExpanded]="openAsExpanded"
Expand All @@ -113,6 +135,7 @@ export const ContentProjection: Story = {
props: args,
template: `
<ppw-expandable-card
[headerAriaLabel]="headerAriaLabel"
[openAsExpanded]="openAsExpanded"
[canBeCollapsed]="canBeCollapsed">
<span ppw-expandable-card-title style="color: #009b3e; font-weight: bold;">
Expand All @@ -129,3 +152,102 @@ export const ContentProjection: Story = {
`
})
}

export const WithoutVisibleHeader: Story = {
args: { headerAriaLabel: 'expandable-card.card-without-visible-title', openAsExpanded: true, canBeCollapsed: true },
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel ? (headerAriaLabel | translate) : undefined"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div>this is a card without a title or description in the header</div>
</ppw-expandable-card>
`
})
}

export const ProjectedTitleOnly: Story = {
args: { openAsExpanded: false, canBeCollapsed: true },
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div ppw-expandable-card-title class="flex-grow-1"><span>Demo card which can be opened with full-width title</span></div><div>card contents</div>
</ppw-expandable-card>
`
})
}

export const ProjectedDescriptionOnly: Story = {
args: { openAsExpanded: false, canBeCollapsed: true },
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div ppw-expandable-card-description class="flex-grow-1"><span>Demo card which can be opened with full-width description</span></div><div>card contents</div>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Image The description should appear on the right. Now it look as if it is a title

</ppw-expandable-card>
`
})
}

export const TitleOnly: Story = {
args: {
cardTitle: 'Demo card which can be opened with a full-width title passed as parameter',
openAsExpanded: false,
canBeCollapsed: true
},
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel" [cardTitle]="cardTitle"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div>card contents</div>
</ppw-expandable-card>
`
})
}

export const DescriptionOnly: Story = {
args: {
cardDescription: 'Demo card which can be opened with a full-width description passed as parameter',
openAsExpanded: false,
canBeCollapsed: true
},
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel" [cardDescription]="cardDescription"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div>card contents</div>
</ppw-expandable-card>
`
})
}

export const CustomHeaderHeights: Story = {
args: { openAsExpanded: true, canBeCollapsed: true },
render: (args) => ({
props: args,
template: `
<ppw-expandable-card [headerAriaLabel]="headerAriaLabel" style="--ppw-expandable-card-header-height-collapsed: 48px; --ppw-expandable-card-header-height-expanded: 64px;"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div ppw-expandable-card-title><span>Demo card which can be collapsed and has a specific height</span></div><div ppw-expandable-card-description class="flex-grow-1 flex-row justify-content-end"><span>Description as content</span></div><div>card contents</div>
</ppw-expandable-card>
`
})
}

export const ExpansionState: Story = {
args: { cardTitle: 'Card with expansion state information', openAsExpanded: false, canBeCollapsed: true },
render: (args) => ({
props: args,
template: `
<ppw-expandable-card #conditionalCard [headerAriaLabel]="headerAriaLabel" [cardTitle]="cardTitle"
[openAsExpanded]="openAsExpanded" [canBeCollapsed]="canBeCollapsed">
<div ppw-expandable-card-description class="flex-grow-1 flex-row justify-content-end"><span>Is expanded: {{ conditionalCard.panelOpenState }}</span></div><div>card contents</div>
</ppw-expandable-card>
`
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { JsonPipe } from '@angular/common'
import { ChangeDetectionStrategy, Component, input, OnInit, viewChild } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { FormArray, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'
import { MatIconButton } from '@angular/material/button'
import { MatCardModule } from '@angular/material/card'
import { MatFormFieldModule } from '@angular/material/form-field'
import { MatIcon } from '@angular/material/icon'
import { MatInput } from '@angular/material/input'
import { TranslateModule } from '@ngx-translate/core'
import { Meta, moduleMetadata, StoryObj } from '@storybook/angular-vite'
import { map } from 'rxjs'
import { FormTableComponent } from './form-table.component'
import { PpwTableOptions } from './options/table-options'
import { PpwTableModule } from './table.module'

type TodoForm = FormGroup<{ label: FormControl<string> }>

@Component({
selector: 'ppw-form-table-story',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [
JsonPipe,
ReactiveFormsModule,
PpwTableModule,
MatCardModule,
MatFormFieldModule,
MatInput,
MatIcon,
MatIconButton,
TranslateModule
],
template: `
<div class="flex-column gap-8 padding-8">
<mat-card>
<mat-card-content>
<form [formGroup]="todoForm">
<ppw-form-table [data]="todos" [options]="tableOptions" [trackBy]="trackByFn">
<ppw-column name="label" type="template" label="Todo's">
<ng-template ppw-column-cell let-record>
<mat-form-field class="full-width">
<mat-label>Enter a todo item</mat-label>
<input matInput [formControl]="record.controls.label" />
</mat-form-field>
</ng-template>
</ppw-column>
<ppw-column name="actions" type="template">
<ng-template ppw-column-header>
<button mat-icon-button type="button" aria-label="Add todo" (click)="addItem()">
<mat-icon>add</mat-icon>
</button>
</ng-template>
<ng-template ppw-column-cell let-record>
<button
mat-icon-button
type="button"
aria-label="Delete todo"
(click)="removeItem(record)"
>
<mat-icon>delete</mat-icon>
</button>
</ng-template>
</ppw-column>
</ppw-form-table>
</form>
</mat-card-content>
</mat-card>
<mat-card>
<mat-card-header>
<mat-card-title>Current form array value:</mat-card-title>
</mat-card-header>
<mat-card-content>
<pre data-testid="form-value">{{ formValue() | json }}</pre>
</mat-card-content>
</mat-card>
</div>
`
})
class FormTableStoryComponent implements OnInit {
readonly initialTodos = input<string[]>([])
protected readonly table = viewChild.required<FormTableComponent<TodoForm>>(FormTableComponent)
protected readonly todos = new FormArray<TodoForm>([])
protected readonly todoForm = new FormGroup({ todos: this.todos })
protected readonly formValue = toSignal(this.todos.valueChanges.pipe(map(() => this.todos.getRawValue())), {
initialValue: this.todos.getRawValue()
})
protected readonly tableOptions: PpwTableOptions<TodoForm> = {
header: { sticky: true, styles: { actions: () => ({ 'text-align': 'right' }) } },
columns: {
styles: { actions: () => ({ 'text-align': 'right' }) },
widths: { label: '100%', actions: '80px' }
},
rows: { highlightOnHover: false }
}
protected readonly trackByFn = (_index: number, control: TodoForm): TodoForm => control

ngOnInit(): void {
for (const label of this.initialTodos()) {
this.todos.push(this.createTodo(label))
}
}

protected addItem(): void {
this.table().addControl(this.createTodo(''))
}

protected removeItem(control: TodoForm): void {
this.table().removeControl(control)
}

private createTodo(label: string): TodoForm {
return new FormGroup({ label: new FormControl(label, { nonNullable: true, validators: Validators.required }) })
}
}

const meta: Meta<FormTableStoryComponent> = {
title: 'ng-common-components/Table/Form',
component: FormTableStoryComponent,
subcomponents: { FormTableComponent },
decorators: [moduleMetadata({ imports: [FormTableStoryComponent, TranslateModule] })],
tags: ['autodocs'],
argTypes: {
initialTodos: {
control: false,
description: 'Initial todo labels, used when the story is created. Edit rows directly in the table.'
}
},
parameters: {
docs: {
description: {
component:
'Editable reactive-form rows backed by a FormArray of FormGroups. Add and remove rows through the form table methods, edit required todo labels, and inspect the live form value below.'
}
}
}
}

export default meta
type Story = StoryObj<FormTableStoryComponent>

export const Empty: Story = {
args: { initialTodos: [] }
}

export const Populated: Story = {
args: { initialTodos: ['Review the pull request', 'Update the documentation'] }
}
Loading
Loading