diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.html b/libs/ui/components/src/lib/content-hero/content-hero.component.html index cd5899744..3ac415115 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.html +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.html @@ -142,9 +142,37 @@ @if (description()) { -

+

{{ description() }}

+ @if (hasDescriptionOverflow() || isDescriptionExpanded()) { + + } } diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.scss b/libs/ui/components/src/lib/content-hero/content-hero.component.scss index 48dedce1e..20ec01b63 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.scss +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.scss @@ -322,7 +322,7 @@ font-size: 0.9375rem; line-height: 1.6; color: var(--text-secondary); - margin-bottom: 20px; + margin-bottom: 6px; max-width: 600px; // Truncate long descriptions @@ -330,6 +330,58 @@ -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; + + &--expanded { + display: block; + -webkit-line-clamp: unset; + overflow: visible; + } + } + + &__description-toggle { + display: inline-flex; + align-items: center; + gap: 4px; + margin: 0 0 20px 0; + padding: 4px 0; + background: transparent; + border: 0; + cursor: pointer; + color: var(--text-secondary); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; + transition: + color 180ms ease, + transform 180ms ease; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + transition: transform 180ms ease; + } + + &:hover { + color: var(--text-primary); + + mat-icon { + transform: translateY(2px); + } + } + + &[aria-expanded='true']:hover mat-icon { + transform: translateY(-2px); + } + + &:focus-visible { + outline: 2px solid var(--accent-color); + outline-offset: 2px; + border-radius: 2px; + } } &__meta { @@ -483,6 +535,10 @@ &__description { -webkit-line-clamp: 3; + + &--expanded { + -webkit-line-clamp: unset; + } } &__meta { diff --git a/libs/ui/components/src/lib/content-hero/content-hero.component.ts b/libs/ui/components/src/lib/content-hero/content-hero.component.ts index a04f8610b..8703858ec 100644 --- a/libs/ui/components/src/lib/content-hero/content-hero.component.ts +++ b/libs/ui/components/src/lib/content-hero/content-hero.component.ts @@ -1,16 +1,35 @@ -import { Component, computed, input, output, signal } from '@angular/core'; +import { + Component, + DestroyRef, + computed, + effect, + inject, + input, + output, + signal, + viewChild, + ElementRef, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader'; @Component({ selector: 'app-content-hero', standalone: true, - imports: [MatIconModule, MatButtonModule, NgxSkeletonLoaderComponent], + imports: [ + MatIconModule, + MatButtonModule, + NgxSkeletonLoaderComponent, + TranslateModule, + ], templateUrl: './content-hero.component.html', styleUrls: ['./content-hero.component.scss'], }) export class ContentHeroComponent { + private readonly destroyRef = inject(DestroyRef); + readonly title = input(); readonly description = input(); readonly posterUrl = input(); @@ -21,6 +40,26 @@ export class ContentHeroComponent { readonly backClicked = output(); readonly posterError = signal(false); + readonly descriptionEl = viewChild>('descriptionEl'); + readonly isDescriptionExpanded = signal(false); + readonly hasDescriptionOverflow = signal(false); + + private resizeObserver?: ResizeObserver; + + constructor() { + effect(() => { + // Re-measure whenever description content or the element changes. + this.description(); + const el = this.descriptionEl()?.nativeElement; + if (!el) return; + + this.measureOverflow(el); + this.observeOverflow(el); + }); + + this.destroyRef.onDestroy(() => this.resizeObserver?.disconnect()); + } + onPosterError(): void { this.posterError.set(true); } @@ -57,4 +96,21 @@ export class ContentHeroComponent { onBack(): void { this.backClicked.emit(); } + + toggleDescription(): void { + this.isDescriptionExpanded.update((v) => !v); + } + + private measureOverflow(el: HTMLElement): void { + // Measure only in the clamped state; if already expanded, clamped overflow + // is implied when the element previously overflowed. + if (this.isDescriptionExpanded()) return; + this.hasDescriptionOverflow.set(el.scrollHeight > el.clientHeight + 1); + } + + private observeOverflow(el: HTMLElement): void { + this.resizeObserver?.disconnect(); + this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el)); + this.resizeObserver.observe(el); + } }