Skip to content
Merged
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
@@ -1,5 +1,5 @@
<div class="container">
<div class="text-content" role="region" [attr.aria-label]="name || 'Content'">
<div class="text-content" role="region" [attr.aria-label]="ariaLabel || name || 'Content'">
<ng-container *ngIf="nonCollapsible; else animated">
<div id="{{name}}" #description [innerHtml]="content | detectLanguage"
class="full-height"></div>
Expand All @@ -26,7 +26,7 @@
(keydown.space)="openShut(); $event.preventDefault()"
[attr.aria-expanded]="!isTruncating"
[attr.aria-controls]="name"
[attr.aria-label]="isTruncating ? 'Show more content' : 'Show less content'"
[attr.aria-label]="isTruncating ? ('Show more of ' + (ariaLabel || 'content')) : ('Show less of ' + (ariaLabel || 'content'))"
i18n-aria-label
class="ion-focusable">
<ng-container *ngIf="isTruncating">
Expand Down
56 changes: 21 additions & 35 deletions projects/v3/src/app/components/description/description.component.ts
Original file line number Diff line number Diff line change
@@ -1,37 +1,26 @@
import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnChanges, SimpleChange, ViewEncapsulation, Output, EventEmitter } from '@angular/core';
import { Component, Input, ViewChild, ElementRef, AfterViewInit, OnChanges, SimpleChanges, ViewEncapsulation, Output, EventEmitter } from '@angular/core';
import { SafeHtml } from '@angular/platform-browser';
import { BrowserStorageService } from '@v3/services/storage.service';

@Component({
selector: 'app-description',
templateUrl: 'description.component.html',
styleUrls: ['./description.component.scss'],
encapsulation: ViewEncapsulation.ShadowDom,
/*animations: [
trigger('truncation', [
state('show', style({
'max-height': '1000px !important',
})),
state('hide', style({
'max-height': '90px !important',
})),
transition('* <=> *', [
animate('0.5s ease-in-out')
])
]),
]*/
encapsulation: ViewEncapsulation.None,
})
export class DescriptionComponent implements OnChanges {
export class DescriptionComponent implements OnChanges, AfterViewInit {
heightLimit = 145; // more accurately adjusted
isTruncating: boolean;
heightExceeded: boolean;
elementHeight: number;
hasBeenTruncated: boolean; // prevent onChange replace the collapsed content

@Input() name; // unique identity of parent element
@Input() content;
@Input() isInPopup;
@Input() name: string; // unique identity of parent element
@Input() content: SafeHtml;
@Input() isInPopup: boolean;
@Input() nonCollapsible?: boolean;
@Output() hasExpanded? = new EventEmitter();
@Input() ariaLabel?: string;
@Output() hasExpanded? = new EventEmitter<boolean>();
@ViewChild('description') descriptionRef: ElementRef;

constructor(
Expand All @@ -40,43 +29,40 @@ export class DescriptionComponent implements OnChanges {
this.hasBeenTruncated = false;
}

ngOnChanges(changes: { [propKey: string]: SimpleChange}) {
// reset to default
if (this.hasBeenTruncated === false) {
ngOnChanges(changes: SimpleChanges) {
if (changes.content && !changes.content.firstChange) {
this.hasBeenTruncated = false;
this.isTruncating = false;
this.heightExceeded = false;
this.calculateHeight();
}
}

this.content = changes.content.currentValue;
ngAfterViewInit() {
this.calculateHeight();
}

calculateHeight(): void {
if (this.nonCollapsible === true) {
if (this.nonCollapsible === true || !this.storage.getUser().truncateDescription) {
return;
}

if (!this.storage.getUser().truncateDescription) {
return;
}
setTimeout(
() => {
setTimeout(() => {
if (this.descriptionRef?.nativeElement) {
this.elementHeight = this.descriptionRef.nativeElement.clientHeight;
this.heightExceeded = this.elementHeight >= this.heightLimit;

if (this.heightExceeded) {
if (this.heightExceeded && !this.hasBeenTruncated) {
this.isTruncating = true;
this.hasBeenTruncated = true;
}
},
700
);
}
}, 300); // Reduced timeout
}

openShut(): void {
this.isTruncating = !this.isTruncating;
this.hasExpanded.emit(!this.isTruncating);
return;
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ <h3 class="for-accessibility" [id]="'multi-team-member-selector-question-' + que
<ion-checkbox
[attr.aria-label]="teamMember.userName"
color="success"
[checked]="(innerValue || submission?.answer)?.includes(teamMember.key)"
[checked]="isSelected(teamMember)"
[value]="teamMember.key"
slot="start"
(ionChange)="onChange(teamMember.key)"
Expand Down
10 changes: 6 additions & 4 deletions projects/v3/src/app/components/topic/topic.component.html
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
<div *ngIf="topic" class="ion-padding main-content" style="min-height: 100%;">
<div class="headline-2 topic-title" aria-live="polite" role="heading" [innerHtml]="sanitizedTitle"></div>
<div class="headline-2 topic-title" aria-live="polite" role="heading" [innerHTML]="sanitizedTitle"></div>
<div *ngIf="topic.videolink && topic.videolink !=='magiclink'" class="text-center topic-video">
<div *ngIf="iframeHtml" class="video-embed" [innerHtml]="iframeHtml"></div>
<div *ngIf="iframeHtml" class="video-embed" [innerHTML]="iframeHtml"></div>
<video
*ngIf="!iframeHtml"
[attr.id]="'topic-video-' + topic.id"
class="video-embed topic-video"
[ngClass]="{'desktop-view': !isMobile}"
width="100%"
Expand All @@ -23,7 +24,7 @@
</span>
</div>
<div class="audio-player">
<audio controls class="audio-element" preload="metadata">
<audio [attr.id]="'topic-audio-' + topic.id" controls class="audio-element" preload="metadata">
<source [src]="topic.audio.link" type="audio/mpeg">
Your browser does not support the audio element.
</audio>
Expand All @@ -32,9 +33,10 @@

<app-description *ngIf="topic.content"
class="body-2"
[name]="'topic'+topic.id"
[name]="'topic-description-' + topic.id"
[content]="topic.content"
[nonCollapsible]="true"
[ariaLabel]="'Topic content'"
></app-description>

<ion-list *ngIf="topic.files && topic.files.length > 0" class="ion-margin-vertical">
Expand Down
7 changes: 6 additions & 1 deletion projects/v3/src/app/components/topic/topic.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,17 @@ export class TopicComponent implements OnInit, OnChanges, OnDestroy {
// convert other brand video players to custom player.
private _initVideoPlayer() {
setTimeout(() => {
this.utils.each(this.document.querySelectorAll('.video-embed'), embedVideo => {
this.utils.each(this.document.querySelectorAll('.video-embed'), (embedVideo, index) => {
embedVideo.classList.remove('topic-video');
if (!this.utils.isMobile()) {
embedVideo.classList.remove('desktop-view');
}
embedVideo.classList.add('plyr__video-embed');

// add unique id to prevent duplicate ids from plyr
const uniqueId = `plyr-${this.topic?.id || 'unknown'}-${index}-${Date.now()}`;
embedVideo.setAttribute('data-plyr-id', uniqueId);

new Plyr(embedVideo as HTMLElement, { ratio: '16:9' });
// if we have video tag, plugin will adding div tags to wrap video tag and main div contain .plyr css class.
// so we need to add topic-video and desktop-view to that div to load video properly .
Expand Down
45 changes: 38 additions & 7 deletions projects/v3/src/app/pipes/language.pipe.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,54 @@
import { Pipe, PipeTransform } from '@angular/core';
import { DomSanitizer, SafeHtml } from '@angular/platform-browser';
import { UtilsService } from '@v3/services/utils.service';

/**
* Pipe to add lang attributes to HTML content for WCAG 3.1.2 Language of Parts compliance
* Processes HTML content and wraps foreign language passages with lang attributes
* Pipe to add lang attributes to HTML content for WCAG 3.1.2 Language of Parts compliance.
* Processes HTML content and wraps foreign language passages with lang attributes.
* Handles SafeHtml and includes caching for performance.
*/
@Pipe({
name: 'detectLanguage',
standalone: false
})
export class LanguageDetectionPipe implements PipeTransform {
constructor(private utils: UtilsService) {}
private lastContent: string | SafeHtml | null | undefined;
private lastResult: SafeHtml;

transform(htmlContent: string | null | undefined, defaultLang?: string): string {
if (!htmlContent) {
return '';
constructor(
private utils: UtilsService,
private sanitizer: DomSanitizer
) {}

transform(htmlContent: string | SafeHtml | null | undefined, defaultLang?: string): SafeHtml {
if (htmlContent === this.lastContent) {
return this.lastResult;
}

let contentString: string;
if (typeof htmlContent === 'string') {
contentString = htmlContent;
} else if (htmlContent instanceof Object && 'changingThisBreaksApplicationSecurity' in htmlContent) {
// This is a way to check if it's a SafeHtml object without private APIs.
// The ideal way is to get the raw string, but SafeHtml is opaque.
// This workaround extracts the value, but it's fragile.
// A better long-term solution is to apply language detection *before* sanitization.
contentString = (htmlContent as any).changingThisBreaksApplicationSecurity;
} else {
contentString = '';
}

return this.utils.addLanguageAttributes(htmlContent, defaultLang);
if (!contentString) {
this.lastResult = this.sanitizer.bypassSecurityTrustHtml('');
this.lastContent = htmlContent;
return this.lastResult;
}

const processedContent = this.utils.addLanguageAttributes(contentString, defaultLang);
this.lastResult = this.sanitizer.bypassSecurityTrustHtml(processedContent);
this.lastContent = htmlContent;

return this.lastResult;
}
}

9 changes: 6 additions & 3 deletions projects/v3/src/app/services/ngx-embed-video.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,20 +58,23 @@ export class EmbedVideoService {

public embed_youtube(id: string, options?: any): SafeHtml {
options = this.parseOptions(options);
const uniqueId = `youtube-embed-${id}-${Date.now()}`;

return this.sanitize_iframe(`<iframe src="https://www.youtube.com/embed/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://www.youtube.com/embed/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
}

public embed_vimeo(id: string, options?: any): SafeHtml {
options = this.parseOptions(options);
const uniqueId = `vimeo-embed-${id}-${Date.now()}`;

return this.sanitize_iframe(`<iframe src="https://player.vimeo.com/video/${id}${options.query}"${options.attr} frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>`);
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://player.vimeo.com/video/${id}${options.query}"${options.attr} frameborder="0" webkitallowfullscreen mozallowfullscreen allowfullscreen></iframe>`);
}

public embed_dailymotion(id: string, options?: any): SafeHtml {
options = this.parseOptions(options);
const uniqueId = `dailymotion-embed-${id}-${Date.now()}`;

return this.sanitize_iframe(`<iframe src="https://www.dailymotion.com/embed/video/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
return this.sanitize_iframe(`<iframe id="${uniqueId}" src="https://www.dailymotion.com/embed/video/${id}${options.query}"${options.attr} frameborder="0" allowfullscreen></iframe>`);
}

public embed_image(url: any, options?: any): Promise<{
Expand Down