EN VI

Angular - How to add a wrapping element around groups of projected items?

2024-03-15 23:30:09
Angular - How to add a wrapping element around groups of projected items?

I am trying to write a bunch of component that would encapsulate layout logic similar to Bootstrap's grid layout system. The list of items is dynamic, and the items are not homogeneous i.e. it can be a mix of string nodes, components or just a bunch of native HTML elements.

So the desired interface for using this component is as follows:

<app-layout [columns]="2">
  <app-layout-item *ngFor="let item of dynamicItems">
    {{ item }}
  </app-layout-item>
  <app-layout-item>
    Some text
  </app-layout-item>
  <app-layout-item>
    <button></button>
  </app-layout-item>
</app-layout>

Now I implemented the components:

@Component({
  selector: 'app-layout-item',
  standalone: true,
  templateUrl: `
    <ng-template>
      <ng-content></ng-content>
    </ng-template>
  `,
  styleUrl: './layout-item.component.css',
})
export class LayoutItemComponent extends LifecycleLogger {
  @ViewChild(TemplateRef) public template!: TemplateRef<void>;
}


function chunk<T>(array: T[], size: number): T[][] {
  let result = [];
  for (let i = 0; i < Math.ceil(array?.length / size); i++) {
    result.push(array.slice(i * size, i * size + size));
  }
  return result;
}

@Component({
  selector: 'app-layout',
  standalone: true,
  imports: [CommonModule],
  templateUrl: `
    <div *ngFor="let row of rows" class="row">
      <div *ngFor="let item of row" class="col">
        <ng-template [ngTemplateOutlet]="item.template"></ng-template>
      </div>
    </div>
  `,
  styleUrl: './layout.component.css',
})
export class LayoutComponent {
  @Input({ required: true }) columns!: number;

  @ContentChildren(LayoutItemComponent)
  items!: QueryList<LayoutItemComponent>;

  rows!: LayoutItemComponent[][];

  ngAfterContentInit() {
    this.rows = chunk(this.items?.toArray().filter(Boolean), this.columns);
  }
}

It renders everything, but I'm getting ExpressionChangedAfterItHasBeenCheckedError if there is an item in projected content with no structural directive applied on. I checked the lifecycle hooks order and found out that applying a structural directive makes the component ngOnViewInit fire before ngOnViewInit, but for those items not having a structural directive ngOnViewInit fires later so their template is updated after app-layout renders causing the error.

Here is repl.

So the question is: how does one add a wrapping element around groups of projected items that may or may not have a structural directive applied?

  1. I defenitely could make such a layout component with pure css, so I would not need to fit these row divs so that app-layout template would be as simple as <ng-content></ng-content>. The problem with this approach is the fact that acctual css styles for this layout do not belong to the app but loaded at runtime and I have to use them.
  2. I played around with app-layout @ContentChildren, tried to get TemplateRef directly but this returns an empty list that never updates, and I don't see a big difference to overall situation.
  3. I could apply *ngIf="true" to every "static" projected item but this seems weird at least.
  4. I could turn app-layout-item into a structural directive so that I ensure every item has one, and the directive would just call createEmbeddedView unconditionally. This feels as a workaround that relies on angular handling structural directives.
  5. I could do direct DOM manipulations, but it increases a chanse to shoot a leg and I don't really want to do this.

Solution:

We can use the static: true of the ViewChild, which is set to default as false. In the documentation it says

static - true to resolve query results before change detection runs, false to resolve after change detection. Defaults to false.

So when the query resolves before change detection runs, the before change detection and after detection shows the same values and will get rid of this error!

...
export class LayoutItemComponent extends LifecycleLogger {
  @ViewChild(TemplateRef, { static: true }) public template!: TemplateRef<void>; // <- changed here
  ...

By doing these changes, It just makes the template to be fetched before change detection runs, this has the advantage of the template being ready before the view is initialized, so we do not get the error, as to why the hooks are firing earlier, like said above, since static is true, the timing got changed is my understanding, but we need to look into the angular source code to give an exact answer!

Stackblitz Demo

Answer

Login


Forgot Your Password?

Create Account


Lost your password? Please enter your email address. You will receive a link to create a new password.

Reset Password

Back to login