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
1 change: 1 addition & 0 deletions api-goldens/element-ng/accordion/index.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import { AfterContentInit } from '@angular/core';
import * as _angular_core from '@angular/core';
import * as i1 from '@angular/aria/accordion';
import { OnChanges } from '@angular/core';
import { Subject } from 'rxjs';
import { TranslatableString } from '@siemens/element-translate-ng/translate';
Expand Down
4 changes: 3 additions & 1 deletion projects/element-ng/accordion/si-accordion.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
* Copyright (c) Siemens 2016 - 2026
* SPDX-License-Identifier: MIT
*/
import { AccordionGroup } from '@angular/aria/accordion';
import {
AfterContentInit,
booleanAttribute,
Expand Down Expand Up @@ -31,7 +32,8 @@ const PANEL_MIN_HEIGHT = 100;
host: {
'[class.full-height]': 'fullHeight()',
'[class.hcollapsed]': 'collapsed()'
}
},
hostDirectives: [AccordionGroup]
})
export class SiAccordionComponent implements AfterContentInit, OnChanges {
/**
Expand Down
8 changes: 4 additions & 4 deletions projects/element-ng/accordion/si-accordion.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@
import { Injectable, signal } from '@angular/core';
import { Subject } from 'rxjs';

import type { SiCollapsiblePanelComponent } from './si-collapsible-panel.component';

/** @internal */
@Injectable({
providedIn: null
})
export class SiAccordionService {
/**
* Emit an item in the accordion which should be toggled.
*/
readonly toggle$ = new Subject<any>();
/** Emit the panel that was toggled so sibling panels can close. */
readonly toggle$ = new Subject<SiCollapsiblePanelComponent>();
/**
* Subject to emit when the items should be expanded to their full height or restored to normal height.
*
Expand Down
127 changes: 68 additions & 59 deletions projects/element-ng/accordion/si-collapsible-panel.component.html
Original file line number Diff line number Diff line change
@@ -1,65 +1,74 @@
<div
role="button"
placement="start"
[siTooltip]="hcollapsed() && heading() ? (heading()! | translate) : ''"
[class]="`collapsible-header focus-inside ${headerCssClasses()}`"
[class.px-5]="hcollapsed()"
[class.px-6]="!hcollapsed()"
[attr.tabindex]="disabled() ? '' : '0'"
[id]="headerId"
[class.open]="opened()"
[class.disabled]="disabled()"
[attr.aria-expanded]="opened() && !hcollapsed()"
[attr.aria-disabled]="disabled()"
[attr.aria-controls]="controlId"
[attr.aria-label]="hcollapsed() ? (heading() | translate) : ''"
(keydown)="keydown($event)"
(click)="doToggle($event)"
>
@let headerIcon = icon();
@if (headerIcon) {
<si-icon
class="icon"
[class.p-1]="hcollapsed()"
[class.pe-2]="!hcollapsed()"
[icon]="headerIcon"
[class.collapsed-icon]="isHCollapsible"
@if (accordionService) {
<ng-container [ngTemplateOutlet]="panelTemplate" />
} @else {
<div #standaloneAccordionGroup class="d-contents" ngAccordionGroup>
<ng-container
[ngTemplateOutlet]="panelTemplate"
[ngTemplateOutletInjector]="accordionGroupInjector()"
/>
}
@if (icon() && badge() !== undefined && badge() !== '') {
<span class="badge-text">
{{ badge() }}
</span>
}
<div class="si-h5 autohide">
{{ heading() | translate }}
<ng-content select="[si-panel-heading]" />
</div>
}

<ng-template #panelTemplate>
<div
ngAccordionTrigger
placement="start"
[panel]="panel"
[siTooltip]="hcollapsed() && heading() ? (heading()! | translate) : ''"
[class]="`collapsible-header focus-inside ${headerCssClasses()}`"
[class.px-5]="hcollapsed()"
[class.px-6]="!hcollapsed()"
[class.open]="opened()"
[class.disabled]="disabled()"
[disabled]="disabled()"
[attr.aria-label]="hcollapsed() ? (heading() | translate) : ''"
[(expanded)]="opened"
(expandedChange)="doToggle($event)"
>
@let headerIcon = icon();
@if (headerIcon) {
<si-icon
class="icon"
[class.p-1]="hcollapsed()"
[class.pe-2]="!hcollapsed()"
[icon]="headerIcon"
[class.collapsed-icon]="isHCollapsible"
/>
}
@if (icon() && badge() !== undefined && badge() !== '') {
<span class="badge-text">
{{ badge() }}
</span>
}
<div class="si-h5 autohide">
{{ heading() | translate }}
<ng-content select="[si-panel-heading]" />
</div>

@if (badge() !== undefined && badge() !== '') {
<span [class]="'badge autohide' + (badgeColor() ? ' bg-' + badgeColor() : '')">
{{ badge() }}
@if (badge() !== undefined && badge() !== '') {
<span [class]="'badge autohide' + (badgeColor() ? ' bg-' + badgeColor() : '')">
{{ badge() }}
</span>
}
<span class="ms-auto autohide">
<si-icon class="icon dropdown-caret" [icon]="icons.elementDown2" />
</span>
}
<span class="ms-auto autohide">
<si-icon class="icon dropdown-caret" [icon]="icons.elementDown2" />
</span>
</div>
<div
#content
role="region"
[id]="controlId"
[class]="`collapsible-content ${contentBgClasses()}`"
[attr.aria-labelledby]="headerId"
[class.full-height]="fullHeight()"
>
@if (opened()) {
<div class="content-motion" animate.enter="content-enter" animate.leave="content-leave">
<div class="overflow-hidden">
<div [class]="contentCssClasses()">
<ng-content />
</div>
<div
#content
#panel="ngAccordionPanel"
ngAccordionPanel
[class]="`collapsible-content ${contentBgClasses()}`"
[class.full-height]="fullHeight()"
>
<ng-template ngAccordionContent>
<div class="content-motion" animate.enter="content-enter" animate.leave="content-leave">
<div class="overflow-hidden">
<div [class]="contentCssClasses()">
<ng-content />
</div>
</div>
</div>
</div>
}
</div>
</ng-template>
</div>
</ng-template>
60 changes: 35 additions & 25 deletions projects/element-ng/accordion/si-collapsible-panel.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,13 @@
* Copyright (c) Siemens 2016 - 2026
* SPDX-License-Identifier: MIT
*/
import {
AccordionContent,
AccordionGroup,
AccordionPanel,
AccordionTrigger
} from '@angular/aria/accordion';
import { NgTemplateOutlet } from '@angular/common';
import {
booleanAttribute,
Component,
Expand All @@ -12,7 +19,8 @@ import {
model,
output,
signal,
viewChild
viewChild,
ViewContainerRef
} from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { elementDown2 } from '@siemens/element-icons';
Expand All @@ -25,11 +33,18 @@ import { filter } from 'rxjs';
import { SiAccordionHCollapseService } from './si-accordion-hcollapse.service';
import { SiAccordionService } from './si-accordion.service';

let controlIdCounter = 1;

@Component({
selector: 'si-collapsible-panel',
imports: [SiIconComponent, SiTranslatePipe, SiTooltipDirective],
imports: [
AccordionContent,
AccordionGroup,
AccordionPanel,
AccordionTrigger,
NgTemplateOutlet,
SiIconComponent,
SiTranslatePipe,
SiTooltipDirective
],
templateUrl: './si-collapsible-panel.component.html',
styleUrl: './si-collapsible-panel.component.scss',
host: {
Expand Down Expand Up @@ -106,19 +121,23 @@ export class SiCollapsiblePanelComponent {
() => this.accordionHCollapseService?.hcollapsed() ?? false
);
protected readonly fullHeight = computed(() => this.accordionService?.fullHeight() ?? false);
protected controlId = '__si-collapsible-' + controlIdCounter++;
protected headerId = this.controlId + '-header';
private readonly standaloneAccordionGroupView = viewChild('standaloneAccordionGroup', {
read: ViewContainerRef
});
protected readonly accordionGroupInjector = computed(
() => this.standaloneAccordionGroupView()?.injector
);
protected isHCollapsible = false;
protected readonly icons = addIcons({ elementDown2 });
protected readonly disableAnimation = signal(false);

private readonly accordionService = inject(SiAccordionService, { optional: true });
protected readonly accordionService = inject(SiAccordionService, { optional: true });
private readonly accordionHCollapseService = inject(SiAccordionHCollapseService, {
optional: true
});
/** Restore the content scroll position between open/close of the panel. */
private lastScrollPos = 0;
private readonly contentRef = viewChild.required<ElementRef<HTMLElement>>('content');
private readonly contentRef = viewChild<ElementRef<HTMLElement>>('content');

constructor() {
this.isHCollapsible = !!this.accordionHCollapseService;
Expand All @@ -142,32 +161,23 @@ export class SiCollapsiblePanelComponent {
if (open) {
// Restore scroll position after opening
setTimeout(() => {
this.contentRef().nativeElement.scrollTop = this.lastScrollPos;
const content = this.contentRef();
if (content) {
content.nativeElement.scrollTop = this.lastScrollPos;
}
});
} else {
// Save scroll position before closing
this.lastScrollPos = this.contentRef().nativeElement.scrollTop;
this.lastScrollPos = this.contentRef()?.nativeElement.scrollTop ?? 0;
}
}

protected doToggle(event?: Event): void {
if (this.disabled()) {
return;
}

event?.preventDefault();
const opened = this.opened();
this.panelToggle.emit(!opened);
this.openClose(this.hcollapsed() || !opened);
protected doToggle(open: boolean): void {
this.panelToggle.emit(open);
this.openClose(this.hcollapsed() || open);
this.accordionService?.toggle$.next(this);
if (this.hcollapsed()) {
this.accordionHCollapseService?.open$.next(this);
}
}

protected keydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === 'Space' || event.key === ' ') {
this.doToggle(undefined);
}
}
}
Loading