libs/core/dynamic-form/src/form/form.component.ts

Implements

OnInit OnDestroy

Metadata

Relationships

Index

Properties
Methods
Inputs
Outputs

Constructor

constructor()

Inputs

existingFormGroup
Type : FormGroup | undefined

This optional input allows it to hand over a formGroup that the Dynamic Form should use. It will then add the formControls of the fields directly to this formGroup, instead of creating a new one.

formConfig
Type : BaseDynamicFormConfiguration | undefined
Default value : undefined
formRawValue
Type : Record<string, unknown> | undefined
Default value : undefined
id
Type : string
Required :  true
transformedValidationConfiguration
Type : ValidationConfigItem[]
  1. The PFE Facade enriches the validation configuration with state subscriptions for state-driven values within the validations. Outside a Building Block Platform Journey, the standalone Dynamic Form handles this potential enrichment.

  2. The enriched validation configuration is forwarded directly to the TALY applyValidationConfig() method, which applies the validators to the actual form controls.

Outputs

formEvent
Type : DfEventPayload
formGroupChange
Type : UntypedFormGroup
formRawValue
Type : Record<string, unknown> | undefined
formValid
Type : boolean

Methods

determineLayout
determineLayout(formFieldConfig?: BaseDynamicFormConfiguration<DfBaseConfig>)
Parameters :
Name Type Optional
formFieldConfig BaseDynamicFormConfiguration<DfBaseConfig> Yes
Returns : void
getColumnClassList
getColumnClassList(layout: DfFormLayout, config: DfBaseConfig[])
Parameters :
Name Type Optional
layout DfFormLayout No
config DfBaseConfig[] No
Returns : any
updateFormConfig
updateFormConfig(newFormConfig?: BaseDynamicFormConfiguration)

This tears down the old form and renders a new one, if a configuration is given

Parameters :
Name Type Optional
newFormConfig BaseDynamicFormConfiguration Yes
Returns : void

Properties

columnClassList
Type : unknown
Default value : signal<string[]>([])
containerLayout
Type : unknown
Default value : signal<ContainerLayout>(ContainerLayout.SingleColumn)
dynamicFormFields
Type : QueryList<DfFormfieldComponent>
Decorators :
@ViewChildren('dynamicFormField')
formDataToBeRestoredAfterRendering
Type : Record<string | unknown> | undefined
Default value : undefined
formInitFinished
Type : unknown
Default value : outputFromObservable( toObservable(this.renderingInProgress).pipe(map((renderingInProgress) => !renderingInProgress)) )

formInitFinished will be triggered, once all components within the dynamic form have finished their initialization.

isRetailChannel
Type : unknown
Default value : inject(CHANNEL_TOKEN) === CHANNEL.RETAIL
import { type AclRule, createAclPath } from '@allianz/taly-acl';
import { ACL_TAG_TOKEN, AclService, wrapAclEvaluationWithTag } from '@allianz/taly-acl/angular';
import { autoFormBindingFactory } from '@allianz/taly-acl/form-support';
import {
  CHANNEL,
  CHANNEL_TOKEN,
  TalyValidationService,
  ValidationConfig,
  ValidationConfigItem
} from '@allianz/taly-core';
import { dasherize } from '@angular-devkit/core/src/utils/strings';
import {
  afterNextRender,
  ChangeDetectionStrategy,
  ChangeDetectorRef,
  Component,
  effect,
  inject,
  Injector,
  input,
  model,
  OnDestroy,
  OnInit,
  output,
  QueryList,
  signal,
  untracked,
  ViewChildren
} from '@angular/core';
import { outputFromObservable, outputToObservable, toObservable } from '@angular/core/rxjs-interop';
import { FormGroup, type UntypedFormGroup, ValidatorFn } from '@angular/forms';
import isEqual from 'lodash-es/isEqual';
import { combineLatest, of, type ReplaySubject, Subject } from 'rxjs';
import {
  distinctUntilChanged,
  map,
  startWith,
  switchMap,
  take,
  takeUntil,
  tap
} from 'rxjs/operators';
import {
  BaseDynamicFormConfiguration,
  DfBaseConfig,
  type DfEventPayload,
  type DfFormfieldSpacing,
  SingleInputFieldLayout
} from '../base/base.model';
import { DfFormfieldComponent } from '../formfield/formfield.component';
import {
  DfFormLayout,
  DfFormLayoutClassName,
  DfFormLayoutType
} from '../utils/form-layout/form-layout.model';

const ContainerLayout = {
  SingleColumn: 'single-column',
  MultiColumn: 'multi-column',
  CustomColumn: 'custom-column'
} as const;
type ContainerLayout = (typeof ContainerLayout)[keyof typeof ContainerLayout];

type EnrichedFormFieldConfig = [
  DfBaseConfig,
  /** The ACL tag associated with this form field */
  string
];

@Component({
  selector: 'df-form',
  templateUrl: './form.component.html',
  styleUrls: ['./form.component.scss'],
  host: { '[class.rendering-in-progress]': 'renderingInProgress()' },
  standalone: false,
  // This component renders dynamic form fields whose child components can be user-owned.
  // It must use Eager change detection so that those components are checked properly.
  // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection
  changeDetection: ChangeDetectionStrategy.Eager
})
export class InternalDfFormComponent implements OnInit, OnDestroy {
  id = input.required<string>();

  formConfig = input<BaseDynamicFormConfiguration | undefined>(undefined);

  /**
   * This optional input allows it to hand over a formGroup that the Dynamic Form should use.
   * It will then add the formControls of the fields directly to this formGroup, instead of creating a new one.
   */
  existingFormGroup = input<FormGroup | undefined>();

  formValid = output<boolean>();
  formRawValue = model<Record<string, unknown> | undefined>(undefined);

  formGroupChange = output<UntypedFormGroup>();

  readonly formEvent = output<DfEventPayload>();

  /**
   * 1. The PFE Facade enriches the validation configuration with state subscriptions for state-driven values
   *    within the validations. Outside a Building Block Platform Journey, the standalone Dynamic Form handles
   *    this potential enrichment.
   *
   * 2. The enriched validation configuration is forwarded directly to the TALY `applyValidationConfig()` method,
   *    which applies the validators to the actual form controls.
   */
  transformedValidationConfiguration = input<ValidationConfigItem[]>();

  protected form!: FormGroup;

  protected formFieldConfig = signal<EnrichedFormFieldConfig[] | undefined>(undefined);
  protected formFieldValidationConfigs = signal<(ValidationConfig[] | undefined)[]>([]);

  protected spacing = signal<DfFormfieldSpacing | undefined>(undefined);

  protected renderingInProgress = signal<boolean>(true);

  private formLoadingValidator: ValidatorFn = () => {
    const loading = this.renderingInProgress();
    return loading ? { formLoading: true } : null;
  };

  /**
   * formInitFinished will be triggered, once all components within the dynamic form have finished
   * their initialization.
   */
  formInitFinished = outputFromObservable(
    toObservable(this.renderingInProgress).pipe(map((renderingInProgress) => !renderingInProgress))
  );

  containerLayout = signal<ContainerLayout>(ContainerLayout.SingleColumn);

  @ViewChildren('dynamicFormField') dynamicFormFields!: QueryList<DfFormfieldComponent>;

  columnClassList = signal<string[]>([]);
  isRetailChannel = inject(CHANNEL_TOKEN) === CHANNEL.RETAIL;

  private tearDownForm$ = new Subject<void>();
  private tearDownFieldChangesSubscriptions$ = new Subject<void>();
  private injector = inject(Injector);

  protected formAclPath = signal<string | undefined>(undefined);
  private aclService: AclService = inject(AclService);
  private aclTag = inject(ACL_TAG_TOKEN, {
    optional: true
  });

  formDataToBeRestoredAfterRendering: Record<string, unknown> | undefined = undefined;

  private cdr = inject(ChangeDetectorRef);

  private talyValidationService = inject(TalyValidationService);

  constructor() {
    effect(() => {
      // This represents the two-way binding of the raw form value:
      const formRawValue = this.formRawValue();
      if (!formRawValue) return;
      if (this.renderingInProgress()) {
        // Controls don't exist yet (or were torn down for re-render). patchValue would
        // silently no-op against missing controls, so stash the value for restoreFormData
        // to apply once fields finish mounting.
        this.formDataToBeRestoredAfterRendering = formRawValue;
      } else if (!isEqual(formRawValue, this.form.getRawValue())) {
        this.form.patchValue(formRawValue);
      }
    });

    effect(() => {
      if (this.renderingInProgress()) return;

      const formFieldConfig = this.formFieldConfig();
      if (!formFieldConfig) {
        this.formFieldValidationConfigs.set([]);
        return;
      }

      const validationConfig = this.transformedValidationConfiguration();
      if (validationConfig && validationConfig.length > 0) {
        const validationMap = this.talyValidationService.applyValidationConfig(
          this.form,
          validationConfig
        );
        this.formFieldValidationConfigs.set(
          formFieldConfig.map(([formField]) => validationMap.get(formField.id))
        );
      }
    });

    // When the formConfig input changes, we re-render the form:
    effect(() => {
      this.updateFormConfig(this.formConfig());
    });
  }

  ngOnInit() {
    this.form = this.existingFormGroup() ?? new FormGroup({});
    this.form.addValidators(this.formLoadingValidator);
    this.form.updateValueAndValidity();
    this.formGroupChange.emit(this.form);
  }

  /**
   * This tears down the old form and renders a new one, if a configuration is given
   */
  updateFormConfig(newFormConfig?: BaseDynamicFormConfiguration) {
    this.backupFormRawValue();

    // We cleanup the validation handling of TALY before we change the form configuration
    // Otherwise it would re-apply outdated validation configs automatically to fields with
    // IDs that existed before
    this.talyValidationService.cleanupValidationHandling(this.form);

    this.renderingInProgress.set(true);
    this.formFieldConfig.set(undefined);
    this.tearDownForm$.next();
    // Drop a hint to Angular to destroy the child components.
    // Without this, the previous instances stays alive and interfere with the new ones.
    this.cdr.detectChanges();

    if (!newFormConfig || !newFormConfig.fields) {
      this.formRawValue.set(undefined);
      this.renderingInProgress.set(false);
      this.form.updateValueAndValidity();
      return;
    }

    if (newFormConfig.fields.length === 0) {
      this.formRawValue.set(undefined);
      this.setupFormChangesSubscriptions();
      this.renderingInProgress.set(false);
      this.form.updateValueAndValidity();
      return;
    }

    newFormConfig = structuredClone({
      ...newFormConfig,
      fields: this.withoutConsecutiveLineBreakFields(newFormConfig.fields)
    });
    this.checkDuplicatedFields(newFormConfig);
    this.determineLayout(newFormConfig);
    this.formFieldConfig.set(this.enrichConfigWithAclTags(newFormConfig.fields));

    this.initAcl(newFormConfig);

    this.subscribeToElementControls();
  }

  private backupFormRawValue() {
    const formRawValue = untracked(this.formRawValue);
    this.formDataToBeRestoredAfterRendering = this.form.getRawValue() as Record<string, unknown>;
    if (
      formRawValue &&
      JSON.stringify(this.formDataToBeRestoredAfterRendering) !== JSON.stringify(formRawValue)
    ) {
      // Mismatch between form data and formRawValue detected, that means the input
      // value from formRawValue has not been applied to the form yet.
      // Let's use that one:
      this.formDataToBeRestoredAfterRendering = formRawValue;
    }
  }

  /**
   * When the components get rendered, this method will subscribe to all the componentOrControlInitFinished
   * subjects and trigger all the post-rendering actions.
   * This method also reacts to dynamic field visibility changes (via ACL) by using dynamicFormFields.changes.
   */
  private subscribeToElementControls() {
    // afterNextRender waits for child components to mount and is automatically
    // cancelled if the component is destroyed before it fires.
    afterNextRender(
      () => {
        this.waitForFieldsInitialization$()
          .pipe(takeUntil(this.tearDownForm$))
          .subscribe(() => {
            this.setupAclBinding();
            this.cdr.markForCheck();
          });

        // React to dynamic field visibility changes
        this.dynamicFormFields.changes
          .pipe(
            startWith(this.dynamicFormFields),
            tap(() => {
              this.tearDownFieldChangesSubscriptions$.next();
              this.form.updateValueAndValidity();
            }),
            tap(() => this.subscribeToFormEvents()),
            switchMap(() => this.waitForFieldsInitialization$()),
            takeUntil(this.tearDownForm$)
          )
          .subscribe(() => {
            this.restoreFormData();
            this.setupFormChangesSubscriptions();
            this.renderingInProgress.set(false);
            // After subscribing to the form, we ensure to update the status at least once.
            // This is especially important for forms without any controls, as they would not emit any status at all otherwise.
            this.form.updateValueAndValidity();
            this.cdr.markForCheck();
          });
      },
      { injector: this.injector }
    );
  }

  private restoreFormData(): void {
    if (!this.formDataToBeRestoredAfterRendering) return;

    const fieldsToRestore: Record<string, unknown> = {};
    const pendingFields: Record<string, unknown> = {};

    for (const [key, value] of Object.entries(this.formDataToBeRestoredAfterRendering)) {
      const control = this.form.get([key]);
      if (control) {
        if (!control.dirty) {
          fieldsToRestore[key] = value;
        }
      } else {
        pendingFields[key] = value;
      }
    }

    this.form.patchValue(fieldsToRestore);

    this.formDataToBeRestoredAfterRendering =
      Object.keys(pendingFields).length > 0 ? pendingFields : undefined;
  }

  private subscribeToFormEvents() {
    this.dynamicFormFields.forEach((dynamicFormField: DfFormfieldComponent) => {
      outputToObservable(dynamicFormField.formEvent)
        .pipe(takeUntil(this.tearDownFieldChangesSubscriptions$))
        .subscribe((event: DfEventPayload) => {
          this.formEvent.emit(event);
          this.cdr.markForCheck();
        });
    });
  }

  private waitForFieldsInitialization$() {
    const formElementControls: ReplaySubject<void>[] = this.dynamicFormFields.map(
      (dynamicFormField) => dynamicFormField.componentOrControlInitFinished
    );
    // Use of([]) as fallback for empty form element controls
    return formElementControls.length === 0
      ? of([])
      : combineLatest(formElementControls).pipe(take(1));
  }

  private setupFormChangesSubscriptions(): void {
    this.form.valueChanges
      .pipe(
        takeUntil(combineLatest([this.tearDownFieldChangesSubscriptions$, this.tearDownForm$])),
        distinctUntilChanged(isEqual)
      )
      .subscribe(() => {
        this.formRawValue.set(this.form.getRawValue());
      });
    this.form.statusChanges
      .pipe(
        takeUntil(combineLatest([this.tearDownFieldChangesSubscriptions$, this.tearDownForm$])),
        distinctUntilChanged()
      )
      .subscribe(() => {
        const formValid = this.form.valid || this.form.disabled;
        this.formValid.emit(formValid);
        this.cdr.markForCheck();
      });
  }

  private enrichConfigWithAclTags(fields: DfBaseConfig[]): EnrichedFormFieldConfig[] {
    let lineBreakCounter = 0;
    return fields.map((field): EnrichedFormFieldConfig => {
      if (field.type === 'LINE_BREAK') {
        return [field, `line-break-${lineBreakCounter++}`];
      }
      return [field, dasherize(field.id)];
    });
  }

  /**
   * This handles the ACL setup for the formGroup.
   * The autoFormBindingFactory automatically subscribes to the formGroup and reacts to changes of it.
   */
  private setupAclBinding(): void {
    /**
     * we need to expose our AclService bound to the given aclTag
     * of this instance if any. This ensures that calls to
     * this.acl.canShow will incorporate the given acl tag hierarchy.
     * This is important for anything you do manually in the template
     * or for supporting scripts like `autoFormBindingFactory`
     */
    const wrappedAclService = this.aclTag
      ? wrapAclEvaluationWithTag(this.aclService, this.aclTag)
      : this.aclService;

    /**
     * Only bind ACL for controls declared in this form's own configuration (formFieldConfig).
     * This prevents nested dynamic forms from re-evaluating ACL on the parent form's controls
     * with the wrong aclTag.
     * If the excludes list is not set, the autoFormBindingFactory would eventually subscribe
     * to all controls of the form with an aclTag that only belongs to the nested controls.
     * By calculating the excludes list, we ensure that each dynamic form instance only
     * binds ACL for its own controls with the correct aclTag for each one.
     */
    const ownFieldIds = (this.formFieldConfig() ?? []).map(([field]) => field.id);
    const allControlKeys = Object.keys(this.form.controls);
    const excludes = allControlKeys.filter((key) => !ownFieldIds.includes(key));

    const aclFormBinding = autoFormBindingFactory({ excludes })(wrappedAclService, this.form);
    aclFormBinding.stream$.pipe(takeUntil(this.tearDownForm$)).subscribe();
  }

  private initAcl(config: BaseDynamicFormConfiguration) {
    this.aclService.removeDynamicFormRules(this.id());

    // This collects all the ACL elements above the Dynamic Form in the DOM:
    const getFullAclPath = (aclResource: string): string => {
      const aclKey = this.aclTag ? this.aclTag.aclKey : undefined;
      const pathElements = [aclKey, aclResource].filter(
        (element): element is NonNullable<typeof element> => element !== undefined
      );
      return createAclPath(pathElements);
    };

    this.formAclPath.set(getFullAclPath(''));

    // Inject the ACL rules
    const aclRules: AclRule[] = [];
    for (const field of config.fields) {
      if (field.acl?.length) {
        for (const acl of field.acl) {
          aclRules.push({
            active: true,
            path: getFullAclPath(field.id),
            condition: acl.condition ?? '',
            state: acl.state,
            defaultRule: false,
            dynamicFormRule: true
          });
        }
      }
    }
    if (config.acl?.length) {
      for (const acl of config.acl) {
        aclRules.push({
          active: true,
          path: getFullAclPath(acl.path),
          condition: acl.condition ?? '',
          state: acl.state,
          defaultRule: false,
          dynamicFormRule: true
        });
      }
    }
    this.aclService.addDynamicFormRules(this.id(), aclRules);
  }

  private checkDuplicatedFields(newFormConfig: BaseDynamicFormConfiguration) {
    newFormConfig.fields.forEach((field, index) => {
      if (newFormConfig.fields.findIndex((f) => f.id === field.id) !== index) {
        throw new Error(
          `Dynamic Form: The form configuration contains duplicated field IDs: "${field.id}". Field IDs must be unique.`
        );
      }
    });
  }

  determineLayout(formFieldConfig?: BaseDynamicFormConfiguration<DfBaseConfig>) {
    if (formFieldConfig) {
      const { layout, fields } = formFieldConfig;
      if (layout?.type) {
        this.containerLayout.set(this.determineContainerLayout(layout));
        this.columnClassList.set(this.getColumnClassList(layout, fields));
      } else {
        this.containerLayout.set(ContainerLayout.SingleColumn);
        this.columnClassList.set(
          this.getColumnClassList({ type: DfFormLayoutType.OneColumn }, fields)
        );
      }
    }
  }

  private determineContainerLayout(layout: DfFormLayout) {
    const layoutType = layout.type;
    if (layoutType === DfFormLayoutType.OneColumn) {
      return ContainerLayout.SingleColumn;
    }

    if (layoutType === DfFormLayoutType.CustomColumn) {
      return ContainerLayout.CustomColumn;
    }

    return ContainerLayout.MultiColumn;
  }

  getColumnClassList(layout: DfFormLayout, config: DfBaseConfig[]) {
    return config.map((config) => {
      const type = config.type;

      const FULL_WIDTH_TYPES = new Set([
        'CIRCLE_TOGGLE_GROUP',
        'CHECKBOX_GROUP',
        'RADIO',
        'TOGGLE_BUTTON',
        'NOTIFICATION',
        'TILE',
        'NUMBER_STEPPER'
      ]);

      if (type === 'LINE_BREAK') {
        return `line-break-element`;
      }

      const columnClass = 'column';

      const isFullWidthComponent =
        (type === 'HEADLINE' && !layout.headlineWidthMatchesLayoutType) ||
        (type === 'PARAGRAPH' && !layout.paragraphWidthMatchesLayoutType) ||
        FULL_WIDTH_TYPES.has(type);

      if (isFullWidthComponent) {
        const fullWidthClass =
          layout.type === DfFormLayoutType.CustomColumn ? 'column-full' : 'one-column-element';
        return `${columnClass} ${fullWidthClass}`;
      }

      if (layout.type !== DfFormLayoutType.CustomColumn) {
        return `${columnClass} ${DfFormLayoutClassName[layout.type]}`;
      }

      const isCenterInRetail =
        this.isRetailChannel &&
        'layout' in config &&
        (config as SingleInputFieldLayout)?.layout?.centerAlignInRetail;

      const columnSpanClass = config.columnSpan
        ? `column-${config.columnSpan}${isCenterInRetail ? '-center' : ''}`
        : 'column-full';

      return `${columnClass} ${columnSpanClass}`;
    });
  }

  ngOnDestroy(): void {
    this.tearDownForm$.next();
    this.tearDownFieldChangesSubscriptions$.next();
    this.aclService.removeDynamicFormRules(this.id());
    this.form.removeValidators(this.formLoadingValidator);
    this.form.updateValueAndValidity();
  }

  private withoutConsecutiveLineBreakFields(fields: DfBaseConfig[]): DfBaseConfig[] {
    return fields.filter((field, index) => {
      if (field.type === 'LINE_BREAK') {
        return fields[index - 1]?.type !== 'LINE_BREAK';
      }
      return true;
    });
  }
}
@if (formFieldConfig() && form) {
<form [ngClass]="containerLayout()" [formGroup]="form">
  @for (fieldConfig of formFieldConfig(); track fieldConfig[0].id; let i = $index) {
  <!---->
  <df-formfield
    #dynamicFormField
    *aclTag="fieldConfig[1]"
    [attr.data-render-name]="fieldConfig[0].renderName"
    [config]="fieldConfig[0]"
    [validationConfigs]="formFieldValidationConfigs()[i]"
    [className]="columnClassList()[i]"
    [ngClass]="{ retail: isRetailChannel }"
    [defaultSpacing]="spacing()"
    [isRetailChannel]="isRetailChannel"
    [formAclPath]="formAclPath()"
  ></df-formfield>
  }
</form>
}

./form.component.scss

@use '../breakpoints.scss' as *;

$columns-gap: 32px;
$custom-columns: clamp(2, var(--df-custom-columns, 12), 12);

:host {
  display: block;
}

:host(.rendering-in-progress) {
  // Why is this visibility: hidden and height 0 approach used here?
  // Because display: none caused a change detection endless loop:

  // 1. The textarea component got rendered, which activates a resizeToFitContent functionality. It is called on every change detection cycle in ngDoCheck.
  // This functionality creates a clone of the textarea DOM element and adds it to the DOM to measure the height. This measurement always results in 0 with display: none.

  // 2. The added clone of the textarea in the DOM is detected by the MutationObserver of the TALY frame. This one triggers a change detection cycle.

  // 3. The change detection cycle triggers the resizeToFitContent functionality of the textarea again. As the previous measurement was stored as 0,
  // it tries to do another one and adds another clone of the textarea to the DOM.

  // 4. This new clone is detected by the MutationObserver of the TALY frame, which triggers another change detection cycle.

  // 5. And from hereon out they go into an eternal loop of adding clones and triggering change detection cycles, until a brave developer steps in and
  // kills the browser process.
  visibility: hidden;
  height: 0;
  overflow: hidden;
}

form.single-column {
  display: block;
}

form.multi-column,
form.custom-column {
  display: grid;
  column-gap: $columns-gap;

  @container (max-width: #{$container-breakpoint-m}) {
    display: block;
  }
}

form.custom-column {
  grid-template-columns: repeat(#{$custom-columns}, minmax(0, 1fr));
}

form.multi-column {
  grid-template-columns: repeat(12, minmax(0, 1fr));
}

form.multi-column > .one-column-element {
  grid-column: span 12;
  width: 100%;
}

form.multi-column > .two-column-element {
  grid-column: span 6;
  width: 100%;
}

form.multi-column > .three-column-element {
  grid-column: span 4;
  width: 100%;
}

form.multi-column > .four-column-element {
  grid-column: span 3;
  width: 100%;
}

form > .line-break-element {
  grid-column-end: -1;
}

form.custom-column {
  .column-full {
    grid-column: 1 / -1;
    width: 100%;
  }

  @for $i from 1 through 12 {
    .column-#{$i} {
      grid-column: span #{$i};
      width: 100%;
    }

    .column-#{$i}-center {
      $span: $i * 2;
      grid-column: 1 / -1;
      display: grid;
      grid-template-columns: repeat(calc(2 * #{$custom-columns}), minmax(0, 1fr));
      column-gap: $columns-gap;

      ::ng-deep > * {
        grid-column: calc(#{$custom-columns} - #{$i} + 1) / span #{$span};

        @container (max-width: #{$container-breakpoint-m}) {
          grid-column: 1 / -1;
        }
      }
    }
  }
}
Legend
Html element
Component
Html element with directive

results matching ""

    No results matching ""