mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-16 22:26:16 -08:00
feat(content-hero): add expandable description with toggle functionality and overflow detection
Entire-Checkpoint: c6e522b4276c
This commit is contained in:
1 parent
c0e045f45c
commit
8be51fc246
3 files changed
+144
-4
No files matched your search
@@ -142,9 +142,37 @@
|
||||
|
||||
<!-- Description -->
|
||||
@if (description()) {
|
||||
<p class="details__description">
|
||||
<p
|
||||
#descriptionEl
|
||||
class="details__description"
|
||||
[class.details__description--expanded]="
|
||||
isDescriptionExpanded()
|
||||
"
|
||||
>
|
||||
{{ description() }}
|
||||
</p>
|
||||
@if (hasDescriptionOverflow() || isDescriptionExpanded()) {
|
||||
<button
|
||||
type="button"
|
||||
class="details__description-toggle"
|
||||
(click)="toggleDescription()"
|
||||
[attr.aria-expanded]="isDescriptionExpanded()"
|
||||
>
|
||||
<span>{{
|
||||
(isDescriptionExpanded()
|
||||
? 'SHOW_LESS'
|
||||
: 'SHOW_MORE'
|
||||
) | translate
|
||||
}}</span>
|
||||
<mat-icon aria-hidden="true">
|
||||
{{
|
||||
isDescriptionExpanded()
|
||||
? 'expand_less'
|
||||
: 'expand_more'
|
||||
}}
|
||||
</mat-icon>
|
||||
</button>
|
||||
}
|
||||
}
|
||||
|
||||
<!-- Metadata Grid Slot -->
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string>();
|
||||
readonly description = input<string>();
|
||||
readonly posterUrl = input<string>();
|
||||
@@ -21,6 +40,26 @@ export class ContentHeroComponent {
|
||||
readonly backClicked = output<void>();
|
||||
readonly posterError = signal(false);
|
||||
|
||||
readonly descriptionEl = viewChild<ElementRef<HTMLElement>>('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);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user