diff --git a/src/app/shared/theme-support/themed.component.spec.ts b/src/app/shared/theme-support/themed.component.spec.ts index a0cead6dc5c..e38bf337fe5 100644 --- a/src/app/shared/theme-support/themed.component.spec.ts +++ b/src/app/shared/theme-support/themed.component.spec.ts @@ -85,6 +85,54 @@ describe('ThemedComponent', () => { expect(component.usedTheme).toEqual('custom'); }); })); + + describe('it checks for ngContent and', () => { + + it('returns all child nodes when selector is *', () => { + const element = document.createElement('div'); + element.innerHTML = '12'; + const result = (component as any).getNgContent(element, ['*']); + expect(result.length).toBe(1); + expect(result[0].length).toBe(2); + expect(result[0][0].textContent).toBe('1'); + expect(result[0][1].textContent).toBe('2'); + }); + + it('returns nodes matching specific selector', () => { + const element = document.createElement('div'); + element.innerHTML = '12'; + const result = (component as any).getNgContent(element, ['.match']); + expect(result.length).toBe(1); + expect(result[0].length).toBe(1); + expect(result[0][0].textContent).toBe('1'); + }); + + it('removes selected elements from the DOM', () => { + const element = document.createElement('div'); + element.innerHTML = '12'; + (component as any).getNgContent(element, ['.match']); + expect(element.querySelectorAll('.match').length).toBe(0); + }); + + it('returns empty array when no elements match the selector', () => { + const element = document.createElement('div'); + element.innerHTML = '12'; + const result = (component as any).getNgContent(element, ['.no-match']); + expect(result.length).toBe(1); + expect(result[0].length).toBe(0); + }); + + it('handles multiple selectors', () => { + const element = document.createElement('div'); + element.innerHTML = '12'; + const result = (component as any).getNgContent(element, ['.match1', '.match2']); + expect(result.length).toBe(2); + expect(result[0].length).toBe(1); + expect(result[0][0].textContent).toBe('1'); + expect(result[1].length).toBe(1); + expect(result[1][0].textContent).toBe('2'); + }); + }); }); describe('when the current theme doesn\'t match a themed component', () => { diff --git a/src/app/shared/theme-support/themed.component.ts b/src/app/shared/theme-support/themed.component.ts index 84b1163d7b6..fd9aa592673 100644 --- a/src/app/shared/theme-support/themed.component.ts +++ b/src/app/shared/theme-support/themed.component.ts @@ -5,6 +5,8 @@ import { ComponentRef, ElementRef, HostBinding, + inject, + Injector, OnChanges, OnDestroy, SimpleChanges, @@ -39,6 +41,7 @@ import { ThemeService } from './theme.service'; selector: 'ds-themed', styleUrls: ['./themed.component.scss'], templateUrl: './themed.component.html', + standalone: true, }) export abstract class ThemedComponent implements AfterViewInit, OnDestroy, OnChanges { @ViewChild('vcr', { read: ViewContainerRef }) vcr: ViewContainerRef; @@ -57,6 +60,8 @@ export abstract class ThemedComponent implements AfterViewInit protected inAndOutputNames: (keyof T & keyof this)[] = []; + protected injector = inject(Injector); + /** * A data attribute on the ThemedComponent to indicate which theme the rendered component came from. */ @@ -133,8 +138,15 @@ export abstract class ThemedComponent implements AfterViewInit this.lazyLoadSub = this.lazyLoadObs.subscribe(([simpleChanges, constructor]: [SimpleChanges, GenericConstructor]) => { this.destroyComponentInstance(); + + // Get the ng-content selectors from the component metadata + const ngContentSelectors = this.getComponentNgContentSelectors(constructor); + const projectableNodes = this.getNgContent(this.themedElementContent.nativeElement, ngContentSelectors); + + // Create component without using ComponentFactoryResolver this.compRef = this.vcr.createComponent(constructor, { - projectableNodes: [this.themedElementContent.nativeElement.childNodes], + injector: this.injector, + projectableNodes: projectableNodes, }); if (hasValue(simpleChanges)) { this.ngOnChanges(simpleChanges); @@ -147,6 +159,17 @@ export abstract class ThemedComponent implements AfterViewInit }); } + /** + * Retrieves ng-content selectors from a component type + */ + private getComponentNgContentSelectors(componentType: GenericConstructor): string[] { + const componentDef = (componentType as any).ɵcmp; + if (componentDef && componentDef.ngContentSelectors) { + return componentDef.ngContentSelectors; + } + return ['*']; // Default fallback + } + protected destroyComponentInstance(): void { if (hasValue(this.compRef)) { this.compRef.destroy(); @@ -157,6 +180,28 @@ export abstract class ThemedComponent implements AfterViewInit } } + /** + * Extracts and returns the content nodes from the given element based on the provided ng-content selectors. + * + * @param {Element} element - The DOM element from which to extract content nodes. + * @param {string[]} ngSelectors - An array of ng-content selectors to match against the element's children. + * @returns {Node[][]} - A 2D array where each sub-array contains the nodes matching a specific selector. + */ + protected getNgContent(element: Element, ngSelectors: string[]): Node[][] { + return ngSelectors.map(selector => { + if (selector === '*') { + // If the selector is '*', return all child nodes of the element. + return Array.from(element.childNodes); + } else { + // Otherwise, select and return the nodes matching the specific selector. + const selectedElements = Array.from(element.querySelectorAll(selector)); + // Remove the selected elements from the DOM. + selectedElements.forEach(e => e.remove()); + return selectedElements; + } + }); + } + protected connectInputsAndOutputs(): void { if (isNotEmpty(this.inAndOutputNames) && hasValue(this.compRef) && hasValue(this.compRef.instance)) { this.inAndOutputNames.filter((name: any) => this[name] !== undefined).forEach((name: any) => {