Source

lib/components/layout/layout.component.ts

/**
 * @module module:lib/components/layout/layout.component
 * @description Layout component module.
 * @summary Provides `LayoutComponent` which offers a responsive grid layout
 * for arranging child components using configurable rows, columns and breakpoints.
 * Useful for building responsive UIs that render model and component renderers.
 *
 * @link {@link LayoutComponent}
 */

import { Component, Input, OnInit } from '@angular/core';
import { TranslatePipe } from '@ngx-translate/core';
import { Primitives } from '@decaf-ts/decorator-validation';
import { LayoutGridGaps, UIElementMetadata } from '@decaf-ts/ui-decorators';
import { NgxParentComponentDirective } from '../../engine/NgxParentComponentDirective';
import { KeyValue } from '../../engine/types';
import { IBaseCustomEvent, IComponentProperties } from '../../engine/interfaces';
import { Dynamic } from '../../engine/decorators';
import { filterString } from '../../utils/helpers';
import { ComponentRendererComponent } from '../component-renderer/component-renderer.component';
import { ModelRendererComponent } from '../model-renderer/model-renderer.component';
import { LayoutGridGap } from '../../engine/types';
import { CardComponent } from '../card/card.component';

/**
 * @description Layout component for creating responsive grid layouts in Angular applications.
 * @summary This component provides a flexible grid system that can be configured with dynamic
 * rows and columns. It supports responsive breakpoints and can render child components within
 * the grid structure. The component extends NgxParentComponentDirective to inherit common functionality
 * and integrates with the model and component renderer systems.
 *
 * @class LayoutComponent
 * @extends {NgxParentComponentDirective}
 * @implements {OnInit}
 * @memberOf LayoutComponent
 */
@Dynamic()
@Component({
  selector: 'ngx-decaf-layout',
  templateUrl: './layout.component.html',
  styleUrls: ['./layout.component.scss'],
  imports: [TranslatePipe, CardComponent, ModelRendererComponent, ComponentRendererComponent],
  standalone: true,
})
export class LayoutComponent extends NgxParentComponentDirective implements OnInit {
  /**
   * @description Media breakpoint for responsive behavior.
   * @summary Determines the responsive breakpoint at which the layout should adapt.
   * This affects how the grid behaves on different screen sizes, allowing for
   * mobile-first or desktop-first responsive design patterns. The breakpoint
   * is automatically processed to ensure compatibility with the UI framework.
   *
   * @type {UIMediaBreakPointsType}
   * @default 'medium'
   * @memberOf LayoutComponent
   */
  @Input()
  gap: LayoutGridGap = LayoutGridGaps.collapse;

  /**
   * @description Media breakpoint for responsive behavior.
   * @summary Determines the responsive breakpoint at which the layout should adapt.
   * This affects how the grid behaves on different screen sizes, allowing for
   * mobile-first or desktop-first responsive design patterns. The breakpoint
   * is automatically processed to ensure compatibility with the UI framework.
   *
   * @type {UIMediaBreakPointsType}
   * @default 'medium'
   * @memberOf LayoutComponent
   */
  @Input()
  grid: boolean = true;

  /**
   * @description Media breakpoint for responsive behavior.
   * @summary Determines the responsive breakpoint at which the layout should adapt.
   * This affects how the grid behaves on different screen sizes, allowing for
   * mobile-first or desktop-first responsive design patterns. The breakpoint
   * is automatically processed to ensure compatibility with the UI framework.
   *
   * @type {UIMediaBreakPointsType}
   * @default 'medium'
   * @memberOf LayoutComponent
   */
  @Input()
  flexMode: boolean = false;

  /**
   * @description Media breakpoint for responsive behavior.
   * @summary Determines the responsive breakpoint at which the layout should adapt.
   * This affects how the grid behaves on different screen sizes, allowing for
   * mobile-first or desktop-first responsive design patterns. The breakpoint
   * is automatically processed to ensure compatibility with the UI framework.
   *
   * @type {UIMediaBreakPointsType}
   * @default 'medium'
   * @memberOf LayoutComponent
   */
  @Input()
  rowCard: boolean = true;

  /**
   * @description Maximum number of columns allowed in the grid layout.
   * @summary Specifies the upper limit for the number of columns that can be displayed in the grid.
   * This ensures that the layout remains visually consistent and prevents excessive columns
   * from being rendered, which could disrupt the design.
   *
   * @type {number}
   * @default 6
   * @memberOf LayoutComponent
   */
  @Input()
  private maxColsLength: number = 6;

  /**
   * @description Creates an instance of LayoutComponent.
   * @summary Initializes a new LayoutComponent with the component name "LayoutComponent".
   * This constructor calls the parent NgxParentComponentDirective constructor to set up base
   * functionality and component identification.
   *
   * @memberOf LayoutComponent
   */
  constructor() {
    super('LayoutComponent');
  }

  /**
   * @description Getter that converts columns input to an array format.
   * @summary Transforms the cols input property into a standardized string array format.
   * When cols is a number, it creates an array with that many empty string elements.
   * When cols is already an array, it returns the array as-is. This normalization
   * ensures consistent handling of column definitions in the template.
   *
   * @type {string[]}
   * @readonly
   * @memberOf LayoutComponent
   */
  get _cols(): string[] {
    let cols = this.cols;
    if (typeof cols === Primitives.BOOLEAN) {
      cols = 1;
      this.flexMode = true;
    }
    if (typeof cols === Primitives.NUMBER) cols = Array.from({ length: Number(cols) }, () => '');
    return cols as string[];
  }

  /**
   * @description Calculates the number of columns for a given row.
   * @summary Determines the effective number of columns in a row based on the row's column definitions,
   * the total number of columns in the layout, and the maximum allowed columns.
   *
   * @param {KeyValue | IComponentProperties} row - The row object containing column definitions.
   * @returns {number} The number of columns for the row, constrained by the layout's maximum column limit.
   * @memberOf LayoutComponent
   */
  getRowColsLength(row: KeyValue | IComponentProperties): number {
    let length: number = (row.cols as [])?.length ?? 1;
    const colsLength = (this.cols as [])?.length;
    const rowsLength = (
      typeof this.rows === Primitives.NUMBER ? this.rows : (this.rows as [])?.length
    ) as number;
    if (length > this.maxColsLength) length = this.maxColsLength;

    if (length !== colsLength) {
      length = colsLength;
      if (this.flexMode) {
        length = row.cols.reduce((acc: number, curr: KeyValue) => {
          if (rowsLength > 1)
            return acc + (typeof curr['col'] === Primitives.NUMBER ? curr['col'] : 1);
          return (
            acc +
            (typeof curr['col'] === Primitives.NUMBER
              ? curr['col']
              : curr['col'] === 'full'
                ? 0
                : curr['col'])
          );
        }, 0);
      }
    }
    return length;
  }

  /**
   * @description Getter that converts rows input to an array format.
   * @summary Transforms the rows input property into a standardized string array format.
   * When rows is a number, it creates an array with that many empty string elements.
   * When rows is already an array, it returns the array as-is. This normalization
   * ensures consistent handling of row definitions in the template.
   *
   * @type {KeyValue[]}
   * @readonly
   * @memberOf LayoutComponent
   */
  get _rows(): KeyValue[] {
    let rows = this.rows;
    if (typeof rows === Primitives.NUMBER)
      rows = Array.from({ length: Number(rows) }, () => ({
        title: '',
      })) as Partial<IComponentProperties>[];

    let rowsLength = (rows as string[]).length;
    if (rowsLength === 1 && this.flexMode) {
      this.children.forEach((child) => {
        const row = child?.['props'].row || 1;
        if (row > rowsLength) {
          rowsLength += 1;
          (rows as KeyValue[]).push({ title: '' });
        }
      });

      this.rows = rowsLength;
    }
    return (rows as KeyValue[])
      .map((row, index) => {
        const rowsLength = this.rows;
        return {
          title: typeof row === Primitives.STRING ? row : row?.['title'] || '',
          cols: this.children.filter((child) => {
            let row = (child as UIElementMetadata).props?.['row'] ?? 1;
            if (row > rowsLength) row = rowsLength as number;
            child['col'] =
              (child as UIElementMetadata).props?.['col'] ?? (this.cols as string[])?.length ?? 1;
            if (row === index + 1) return child;
          }),
        };
      })
      .map((row, index) => {
        const colsLength = this.getRowColsLength(row);
        row.cols = row.cols.map((c: KeyValue) => {
          let { col } = c;
          if (typeof col === Primitives.STRING)
            col = col === 'half' ? '1-2' : col === 'full' ? '1-1' : col;

          if (!this.flexMode) {
            if (typeof col === Primitives.NUMBER) {
              col =
                col === colsLength
                  ? `1-1`
                  : col > colsLength
                    ? `${colsLength}-${col}`
                    : `${col}-${colsLength}`;
            }
          } else {
            if (typeof col === Primitives.NUMBER)
              col =
                colsLength <= this.maxColsLength ? `${col}-${colsLength}` : `${index + 1}-${col}`;
            col = ['2-4', '3-6'].includes(col) ? `1-2` : col;
          }
          col = `dcf-child-${col}-${this.breakpoint} dcf-width-${col}`;
          const childClassName = c?.['props']?.className || '';
          const colClass = `${col}@${this.breakpoint} ${filterString(childClassName, '-width-')}`;
          // to prevent layout glitches, before send class to child component remove width classes
          if (c?.['props']?.className)
            c['props'].className = filterString(c?.['props']?.className, '-width-', false);
          return Object.assign(c, { colClass });
        });
        return row;
      });
  }

  override async handleEvent(event: IBaseCustomEvent): Promise<void> {
    this.listenEvent.emit(event);
  }

  /**
   * @description Angular lifecycle hook that runs after component initialization.
   * @summary Called once, after the first ngOnChanges(). This method triggers the
   * component's initialization process, which includes property parsing and grid
   * setup. It ensures the component is properly configured before rendering.
   *
   * @memberOf LayoutComponent
   */
  override async ngOnInit(): Promise<void> {
    // must always parse props first, parse children case of layout depends on it
    super.parseProps(this, !this.children.length ? [] : ['children']);
    if (this.breakpoint)
      this.breakpoint =
        `${this.breakpoint.startsWith('x') ? this.breakpoint.substring(0, 2) : this.breakpoint.substring(0, 1)}`.toLowerCase();
    this.cols = this._cols;
    this.rows = this._rows;

    await super.initialize();
  }
}