Skip to content

Commit 5b0a4f6

Browse files
authored
feat(ngx-layout): Add accordion component
1 parent 171bec1 commit 5b0a4f6

File tree

13 files changed

+489
-3
lines changed

13 files changed

+489
-3
lines changed

apps/layout-test/src/app/app.component.html

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,4 +16,18 @@
1616
<button (click)="sayHello()">Say hello!</button>
1717
<button (click)="confirm()">Confirm!</button>
1818

19-
<router-outlet/>
19+
<ngx-accordion [open]="[1,4]">
20+
@for (item of testData; track $index; let index = $index) {
21+
<ngx-accordion-item [disabled]="index === 2">
22+
<ng-template #headerTmpl let-isOpen>
23+
{{item.title}} {{isOpen}}
24+
</ng-template>
25+
26+
<ng-template #contentTmpl let-isOpen>
27+
{{item.content}}
28+
</ng-template>
29+
</ngx-accordion-item>
30+
}
31+
</ngx-accordion>
32+
33+
<router-outlet />

apps/layout-test/src/app/app.component.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,13 @@ import { tap } from 'rxjs';
55
import { ModalComponent } from '../modal/modal.component';
66
import { NgxMediaQueryService } from '@ngx/utils';
77
import { NgxModalService } from '@ngx/inform';
8+
import { NgxAccordion } from '@ngx/layout';
89

910
@Component({
1011
selector: 'app-root',
1112
templateUrl: './app.component.html',
1213
standalone: true,
13-
imports: [RouterModule],
14+
imports: [RouterModule, NgxAccordion],
1415
})
1516
export class AppComponent {
1617
constructor(
@@ -25,6 +26,29 @@ export class AppComponent {
2526
);
2627
}
2728

29+
public testData = [
30+
{
31+
title: 'Hello',
32+
content: 'World',
33+
},
34+
{
35+
title: 'Hello',
36+
content: 'World',
37+
},
38+
{
39+
title: 'Hello',
40+
content: 'World',
41+
},
42+
{
43+
title: 'Hello',
44+
content: 'World',
45+
},
46+
{
47+
title: 'Hello',
48+
content: 'World',
49+
},
50+
];
51+
2852
public sayHello(): void {
2953
this.modalService
3054
.open({

apps/layout-test/src/styles.scss

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,21 @@
1010
pointer-events: none;
1111
}
1212
}
13+
14+
.ngx-accordion {
15+
display: block;
16+
margin-top: 15px;
17+
}
18+
19+
.ngx-accordion-item {
20+
display: block;
21+
margin-bottom: 10px;
22+
23+
.ngx-accordion-content {
24+
padding: 10px 15px;
25+
}
26+
27+
.ngx-accordion-header {
28+
border-bottom: 1px solid black;
29+
}
30+
}

libs/layout/README.md

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,60 @@ Currently the package provides a `configurable layout` component which can be us
3131

3232
### Components
3333

34+
#### Accordion
35+
36+
The `ngx-accordion` provides a easy to use WCAG/ARIA compliant implementation for an accordion. Its implementation exists of a `NgxAccordionComponent` and a `NgxAccordionItemComponent` component. Both can be imported simultaneously by importing `NgxAccordion`.
37+
38+
##### Implementation
39+
40+
Using the `NgxAccordionComponent` as a container for our accordion items (`NgxAccordionItemComponent`), we are able to provide correct keyboard navigation for end users. Therefore it is important to always warp your items in the container component.
41+
42+
We use content projection to pass the header and the content of each accordion item, by using the `headerTmpl` and `contentTmpl` ng-templates. Both templates allow for fetching the open state of the accordion, by using the $implicit outlet context.
43+
44+
In the example below you can find a simple implementation of the accordion.
45+
```ts
46+
import { NgxAccordion } from '@ngx/layout';
47+
48+
@Component({
49+
...
50+
standalone: true,
51+
imports: [NgxAccordion],
52+
})
53+
```
54+
55+
```html
56+
<ngx-accordion>
57+
@for (item of testData; track $index) {
58+
<ngx-accordion-item>
59+
<ng-template #headerTmpl let-isOpen>
60+
{{item.title}} {{isOpen}}
61+
</ng-template>
62+
63+
<ng-template #contentTmpl>
64+
{{item.content}}
65+
</ng-template>
66+
</ngx-accordion-item>
67+
}
68+
</ngx-accordion>
69+
```
70+
71+
##### Extra configuration
72+
The `NgxAccordionComponent` allows for opening a set of accordion items from the start. This is useful for when you want to open the first item, or specific items in the accordion. By using the `open` property you can either open all items, the first item, a specific item or a set of specific items by passing `all`, `first`, the index of an item or an array of indexes respectively.
73+
74+
An individual `NgxAccordionItemComponent` can also be disabled by providing the `disabled` property. Once disabled, an item's open or closed state cannot be altered by the user. To allow an item to be open from the start and not closeable by the end-user, the `open` property of the `NgxAccordionComponent` will ignore the disabled state of the individual items.
75+
76+
##### Styling
77+
78+
The accordion implementation provides several classes we can use to target elements in the accordion. Internally the `NgxAccordionItemComponent` uses the `details` HTML element.
79+
80+
| Class | |
81+
| ------------------------------ | --------------------------------------------------------------------------- |
82+
| ngx-accordion | A class set to the `NgxAccordionComponent`. |
83+
| ngx-accordion-item | A class set to the `NgxAccordionItemComponent`. |
84+
| ngx-accordion-content | A class set to the content of the accordion item. |
85+
| ngx-accordion-header | A class set to the header of the accordion item. |
86+
87+
3488
#### Configurable layout
3589

3690
The `configurable layout` provides the ability to render components in a grid depending on a provided two dimensional array of keys and corresponding items with a provided template. The combination exists of an `ngx-configurable-layout` and an `ngx-configurable-layout-item`.

libs/layout/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@
1010
"error",
1111
"dynamic dashboard",
1212
"dynamic layout",
13-
"visual"
13+
"visual",
14+
"accordion",
15+
"wcag",
16+
"aria"
1417
],
1518
"homepage": "https://github.com/studiohyperdrive/ngx-tools/tree/master/libs/layout",
1619
"license": "MIT",
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
import { ChangeDetectionStrategy, Component, Input, OnDestroy } from '@angular/core';
2+
import { Subject, take, tap } from 'rxjs';
3+
4+
import { NgxAccordionOpenBehavior } from '../../types';
5+
import { NgxAccordionItemComponent } from './item/accordion-item.component';
6+
7+
/**
8+
* A WCAG/ARIA compliant implementation of the accordion pattern.
9+
*
10+
* https://www.w3.org/WAI/ARIA/apg/patterns/accordion/
11+
*/
12+
@Component({
13+
selector: 'ngx-accordion',
14+
template: '<ng-content/>',
15+
standalone: true,
16+
changeDetection: ChangeDetectionStrategy.OnPush,
17+
host: {
18+
class: 'ngx-accordion',
19+
role: 'region',
20+
},
21+
imports: [NgxAccordionItemComponent],
22+
})
23+
export class NgxAccordionComponent implements OnDestroy {
24+
/**
25+
* A subject to hold a registered event
26+
*/
27+
private itemRegisteredSubject: Subject<void> = new Subject<void>();
28+
29+
/**
30+
* A subject to hold the destroyed event
31+
*/
32+
private destroyedSubject: Subject<void> = new Subject<void>();
33+
34+
/**
35+
* A list of all accordion items
36+
*/
37+
public items: NgxAccordionItemComponent[] = [];
38+
39+
/**
40+
* Open the specific items in the accordion
41+
*/
42+
@Input() public set open(open: NgxAccordionOpenBehavior) {
43+
this.itemRegisteredSubject
44+
.pipe(
45+
take(1),
46+
tap(() => {
47+
// Iben: Use a setTimeOut so we wait an extra tick
48+
setTimeout(() => {
49+
// Iben: Open all items
50+
if (open === 'all') {
51+
this.items.forEach((item) => item.updateAccordionItemState(true));
52+
} else {
53+
// Iben: Open specific items
54+
const indexes =
55+
open === 'first' ? [0] : Array.isArray(open) ? open : [open];
56+
57+
indexes.forEach((index) => {
58+
this.items[index]?.updateAccordionItemState(true);
59+
});
60+
}
61+
});
62+
})
63+
)
64+
.subscribe();
65+
}
66+
67+
/**
68+
* Register an accordion item to the container
69+
*
70+
* @param item - An accordion item
71+
*/
72+
public registerItem(item: NgxAccordionItemComponent): void {
73+
this.itemRegisteredSubject.next();
74+
this.items.push(item);
75+
}
76+
77+
/**
78+
* Removes an accordion item from the container
79+
*
80+
* @param item - An accordion item
81+
*/
82+
public removeItem(item: NgxAccordionItemComponent): void {
83+
// Iben: Get the index of the item
84+
const index = this.items.findIndex(({ id }) => id === item.id);
85+
86+
// Iben: If no item was found, we early exit
87+
if (index === undefined) {
88+
return;
89+
}
90+
91+
// Iben: Remove the item
92+
this.items = [...this.items.slice(0, index), ...this.items.slice(index + 1)];
93+
}
94+
95+
/**
96+
* Moves the focus to an accordion
97+
*
98+
* @param id - The id of the current item
99+
* @param direction - The direction we move in
100+
*/
101+
public moveFocus(id: string, direction: 'up' | 'down' | 'first' | 'last') {
102+
// Iben: If we go to the first or last accordion, we don't need to find the index
103+
if (direction === 'first' || direction === 'last') {
104+
this.items[direction === 'first' ? 0 : this.items.length - 1].focus();
105+
106+
return;
107+
}
108+
109+
// Iben: Find the index and move to the next
110+
const index = this.items.findIndex((item) => id === item.id);
111+
112+
this.items[direction === 'down' ? index + 1 : index - 1]?.focus();
113+
}
114+
115+
/**
116+
* Handle the destroyed state
117+
*/
118+
public ngOnDestroy(): void {
119+
this.destroyedSubject.next();
120+
this.destroyedSubject.complete();
121+
}
122+
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
import { NgxAccordionComponent } from './accordion.component';
2+
import { NgxAccordionItemComponent } from './item/accordion-item.component';
3+
4+
export * from './accordion.component';
5+
export * from './item/accordion-item.component';
6+
export const NgxAccordion = [NgxAccordionComponent, NgxAccordionItemComponent] as const;
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
<details #details [open]="isOpen">
2+
<summary
3+
#summary
4+
class="ngx-accordion-header"
5+
[class.is-disabled]="disabled"
6+
[attr.disabled]="disabled"
7+
[id]="id"
8+
[attr.aria-controls]="'accordion-content-' + id"
9+
[attr.aria-expanded]="isOpen"
10+
(focus)="setFocus(true)"
11+
(blur)="setFocus(false)"
12+
>
13+
<ng-template [ngTemplateOutlet]="headerTemplate" [ngTemplateOutletContext]="{$implicit: isOpen}"/>
14+
</summary>
15+
16+
<div
17+
class="ngx-accordion-content"
18+
role="region"
19+
[id]="'accordion-content-' + id"
20+
[attr.aria-labelledby]="id"
21+
>
22+
<ng-template [ngTemplateOutlet]="contentTemplate" [ngTemplateOutletContext]="{$implicit: isOpen}"/>
23+
</div>
24+
</details>
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
:host {
2+
details > summary {
3+
list-style: none;
4+
}
5+
6+
details > summary::-webkit-details-marker {
7+
display: none;
8+
}
9+
10+
summary {
11+
cursor: pointer;
12+
13+
&.is-disabled {
14+
cursor: not-allowed;
15+
}
16+
}
17+
}

0 commit comments

Comments
 (0)