How to change parent CSS such as <mat-menu> from a standalone component without leaking the CSS to the global style?

49 Views Asked by At

My goal is to edit <mat-menu> CSS that I use in an Angular Standalone Component.

What I've tried:

  1. Use :ng-deep. However, this cause the CSS to leak to the global style, which is not desirable.
<button
    [matMenuTriggerFor]="menu"
    (menuOpened)="isMenuOpened = true;"
    (menuClosed)="isMenuOpened = false;"
    class="button"
    [ngClass]="{
        'button-red': hasCheckedFilter(),
    }">
    <ng-template 
        #title
        let-count="count"
        [ngTemplateOutlet]="title"
        [ngTemplateOutletContext]="{ count: checkedFiltersLength() }">
        <ng-container [ngSwitch]="count">
            <ng-container *ngSwitchCase="0">
                <span>Tipe Dokumen</span>
            </ng-container>
            <ng-container *ngSwitchCase="1">
                <span>{{ formatFirstCheckedFilter() }}</span>
            </ng-container>
            <ng-container *ngSwitchDefault>
                <span>Tipe Dokumen ({{ count }})</span>
            </ng-container>
        </ng-container>
    </ng-template>
    <mat-icon
        [fontIcon]="isMenuOpened ? 'expand_more' : 'expand_less'"></mat-icon>
</button>
<mat-menu #menu="matMenu" class="dashboardPelacakKebijakanFilterMenu"
    (click)="$event.stopPropagation()">
    @for (filter of filters(); track filter.type) {
        <a
            (click)="$event.stopPropagation(); toggleFilter(filters, filter.type)"
            class="dashboardPelacakKebijakanFilterMenu__button"
            routerLink="." [queryParams]="filters() | formatFiltersTipeDokumenToQueryParams: filter.type: filter.checked: activatedRoute.snapshot.queryParams">
            <div>
                <span
                    class="dashboardPelacakKebijakanFilterMenu__button__type">{{ filter.type }}</span>
                <mat-icon
                    [fontIcon]="filter.checked ? 'check_box' : 'check_box_outline_blank'"
                    class="dashboardPelacakKebijakanFilterMenu__button__icon"></mat-icon>
            </div>
        </a>
    }
</mat-menu>
.button {
    // Layout
    display: flex;
    gap: 0.25rem;
    align-items: center;
    padding: 0.5rem 0.75rem;

    // Style
    border-radius: 62.5rem;
    border: 1px solid var(--Color-Gray-gray20, #EAECF0);
    background: var(--Color-Gray-gray5, #FCFCFD);

    // Typography
    color: var(--Color-black, #000);
    /* Body/body2/regular */
    font-family: "DM Sans";
    font-size: 0.875rem;
    font-style: normal;
    font-weight: 400;
    line-height: normal;

    &:hover {
        cursor: pointer;
        background: color-mix(in srgb, #000 15%, transparent);
    }

    &-red {
        // Style
        border-radius: 62.5rem;
        border: 1px solid var(--Color-Red-red, #EC2227);
        background: var(--Color-Red-red20, #FFE3E4);

        // Typography
        color: var(--Color-Red-red120, #CA0006);
        /* Body/body2/regular */
        font-family: "DM Sans";
        font-size: 0.875rem;
        font-style: normal;
        font-weight: 400;
        line-height: normal;
    }
}

// temporary solution.
// ng-deep is deprecated. 
::ng-deep .mat-mdc-menu-panel.dashboardPelacakKebijakanFilterMenu {
    // Style
    border-radius: 0.75rem;
    border: 1px solid var(--Color-Gray-gray20, #EAECF0);
    background: #FFF;

    .mat-mdc-menu-content {
        // Layout
        display: flex;
        flex-direction: column;
        padding: 0;
    }
}

.dashboardPelacakKebijakanFilterMenu {
    &__button {
        // Layout to mimic gap: 0.875rem
        // because of how mat-menu works.
        // 1. if user click the gap, it will close the menu, which is not desirable.
        padding-inline: 1rem;
        &:first-child {
            padding-top: 1rem;
            padding-bottom: calc(0.875rem / 2);
        }
        &:not(:first-child):not(:last-child) {
            padding-block: calc(0.875rem / 2);
        }
        &:last-child {
            padding-top: calc(0.875rem / 2);
            padding-bottom: 1rem;
        }

        // Style
        text-decoration: none;
        color: inherit;

        &:hover {
            cursor: pointer;
            background: color-mix(in srgb, #000 15%, transparent);
        }

        > div {
            // Layout
            display: flex;
            align-items: center;
            gap: 0.75rem;
        }

        &__type {
            // Layout
            flex: 1;

            // Style
            text-align: left;

            // Typography
            color: var(--Grayscale-gray900, #282828);
            /* Body/body2/regular */
            font-family: "DM Sans";
            font-size: 0.875rem;
            font-style: normal;
            font-weight: 400;
            line-height: normal;
        }

        &__icon {
            color: #EC2227;
        }
    }
}
import { Component, OnInit, Signal, WritableSignal, computed, signal } from '@angular/core';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import { FilterTipeDokumen, FilterTipeDokumenType } from '../../../domain/entities/components/dashboard-pelacak-kebijakan-filter-tipe-dokumen/filter-tipe-dokumen';
import { FormatFiltersTipeDokumenToQueryParamsPipe } from '../../pipes/format-filters-tipe-dokumen-to-query-params/format-filters-tipe-dokumen-to-query-params.pipe';
import { NgClass, NgSwitch, NgSwitchCase, NgSwitchDefault, NgTemplateOutlet } from '@angular/common';

@Component({
  selector: 'app-dashboard-pelacak-kebijakan-filter-tipe-dokumen',
  standalone: true,
  imports: [
    MatMenuModule,
    MatIconModule,
    RouterLink,
    FormatFiltersTipeDokumenToQueryParamsPipe,
    NgClass,
    NgTemplateOutlet,
    NgSwitch,
    NgSwitchCase,
    NgSwitchDefault,
  ],
  templateUrl: './dashboard-pelacak-kebijakan-filter-tipe-dokumen.component.html',
  styleUrl: './dashboard-pelacak-kebijakan-filter-tipe-dokumen.component.scss'
})
export class DashboardPelacakKebijakanFilterTipeDokumenComponent implements OnInit {
  isMenuOpened: boolean;
  filters: WritableSignal<FilterTipeDokumen[]>;
  hasCheckedFilter: Signal<boolean>;
  checkedFiltersLength: Signal<number>;
  formatFirstCheckedFilter: Signal<string | undefined>

  constructor(
    public router: Router,
    public activatedRoute: ActivatedRoute,
  ) {
    this.isMenuOpened = false;
    this.filters = signal([]);
    this.hasCheckedFilter = computed(() => false);
    this.checkedFiltersLength = computed(() => 0);
    this.formatFirstCheckedFilter = computed(() => undefined);
  }

  ngOnInit(): void {
    this.filters = this.initializeFilters(this.activatedRoute);
    this.hasCheckedFilter = computed(() => this.filters().some(filter => filter.checked));
    this.checkedFiltersLength = computed(() => this.filters().filter(filter => filter.checked).length);
    this.formatFirstCheckedFilter = computed(() => this.filters().find(filter => filter.checked)?.type)
  }

  private initializeFilters(
    activatedRoute: ActivatedRoute,
  ): WritableSignal<FilterTipeDokumen[]> {
    const filters: WritableSignal<FilterTipeDokumen[]> = signal([
      { type: "Undang-Undang", checked: false, },
      { type: "Peraturan Pemerintah", checked: false, },
      { type: "Peraturan Presiden", checked: false, },
      { type: "Instruksi Presiden", checked: false, },
      { type: "Peraturan Menteri", checked: false, },
      { type: "Keputusan Menteri", checked: false, },
    ]);

    const types = activatedRoute.snapshot.queryParamMap.getAll("type");
    if (types.length === 0) { return filters; }

    filters.update(value => {
      const newValue = [...value];

      newValue.forEach(filter => {
        filter.checked = types.includes(filter.type);
      })

      return newValue;
    })

    return filters;
  }

  toggleFilter(filters: WritableSignal<FilterTipeDokumen[]>, type: FilterTipeDokumenType) {
    filters.update(value => {
      const index = value.findIndex(filter => filter.type === type);
      if (index === -1) { return value; }

      const newValue = [...value];

      const updatedFilter = { ...newValue[index] }
      updatedFilter.checked = !updatedFilter.checked;

      newValue[index] = updatedFilter;

      return newValue;
    });
  }
}
1

There are 1 best solutions below

4
Naren Murali On

You just need to wrap the styles on a parent class (dashboardPelacakKebijakanFilterMenu) and move it to global-styles.scss, this will render the classes only for the popup, we can specify a separate class for other dropdowns so that it does not clash!

Below is a working example for the same!

global-styles.scss

/* Add application styles & imports to this file! */
@import '~@angular/material/prebuilt-themes/indigo-pink.css';

.mat-mdc-menu-panel.dashboardPelacakKebijakanFilterMenu {
  border-radius: 0.75rem;
  border: 1px solid var(--Color-Gray-gray20, #eaecf0);
  background: #fff;
  .mat-mdc-menu-content {
    display: flex;
    flex-direction: column;
    padding: 0;
  }
}
.mat-mdc-menu-panel.dashboardPelacakKebijakanFilterMenu
  .dashboardPelacakKebijakanFilterMenu__button {
  // Layout to mimic gap: 0.875rem
  // because of how mat-menu works.
  // 1. if user click the gap, it will close the menu, which is not desirable.
  padding-inline: 1rem;
  &:first-child {
    padding-top: 1rem;
    padding-bottom: calc(0.875rem / 2);
  }
  &:not(:first-child):not(:last-child) {
    padding-block: calc(0.875rem / 2);
  }
  &:last-child {
    padding-top: calc(0.875rem / 2);
    padding-bottom: 1rem;
  }

  // Style
  text-decoration: none;
  color: inherit;

  &:hover {
    cursor: pointer;
    background: color-mix(in srgb, #000 15%, transparent);
  }

  > div {
    // Layout
    display: flex;
    align-items: center;
    gap: 0.75rem;
  }

  &__type {
    // Layout
    flex: 1;

    // Style
    text-align: left;

    // Typography
    color: var(--Grayscale-gray900, #282828);
    /* Body/body2/regular */
    font-family: 'DM Sans';
    font-size: 0.875rem;
    font-style: normal;
    font-weight: 400;
    line-height: normal;
  }

  &__icon {
    color: #ec2227;
  }
}

component

import {
  Component,
  OnInit,
  Signal,
  WritableSignal,
  computed,
  signal,
} from '@angular/core';
import { MatMenuModule } from '@angular/material/menu';
import { MatIconModule } from '@angular/material/icon';
import { ActivatedRoute, Router, RouterLink } from '@angular/router';
import {
  NgClass,
  NgSwitch,
  NgSwitchCase,
  NgSwitchDefault,
  NgTemplateOutlet,
} from '@angular/common';

@Component({
  selector: 'app-smaller',
  standalone: true,
  imports: [
    MatMenuModule,
    MatIconModule,
    RouterLink,
    NgClass,
    NgTemplateOutlet,
    NgSwitch,
    NgSwitchCase,
    NgSwitchDefault,
  ],
  template: `
  <button
        [matMenuTriggerFor]="menu"
        (menuOpened)="isMenuOpened = true;"
        (menuClosed)="isMenuOpened = false;"
        class="button"
        [ngClass]="{
            'button-red': hasCheckedFilter(),
        }">
        <ng-template 
            #title
            let-count="count"
            [ngTemplateOutlet]="title"
            [ngTemplateOutletContext]="{ count: checkedFiltersLength() }">matMenu
            <ng-container [ngSwitch]="count">
                <ng-container *ngSwitchCase="0">
                    <span>Tipe Dokumen</span>
                </ng-container>
                <ng-container *ngSwitchCase="1">
                    <span>{{ formatFirstCheckedFilter() }}</span>
                </ng-container>
                <ng-container *ngSwitchDefault>
                    <span>Tipe Dokumen ({{ count }})</span>
                </ng-container>
            </ng-container>
        </ng-template>
        <mat-icon
            [fontIcon]="isMenuOpened ? 'expand_more' : 'expand_less'"></mat-icon>
    </button>
    <mat-menu #menu="matMenu" class="dashboardPelacakKebijakanFilterMenu"
        (click)="$event.stopPropagation()">
        @for (filter of filters(); track filter.type) {
            <a
                (click)="$event.stopPropagation(); toggleFilter(filters, filter.type)"
                class="dashboardPelacakKebijakanFilterMenu__button"
                routerLink="." [queryParams]="filters()">
                <div>
                    <span
                        class="dashboardPelacakKebijakanFilterMenu__button__type">{{ filter.type }}</span>
                    <mat-icon
                        [fontIcon]="filter.checked ? 'check_box' : 'check_box_outline_blank'"
                        class="dashboardPelacakKebijakanFilterMenu__button__icon"></mat-icon>
                </div>
            </a>
        }
    </mat-menu>
  `,
  styles: [
    `
    .button {
      display: flex;
      gap: 0.25rem;
      align-items: center;
      padding: 0.5rem 0.75rem;
      border-radius: 62.5rem;
      border: 1px solid var(--Color-Gray-gray20, #EAECF0);
      background: var(--Color-Gray-gray5, #FCFCFD);
      color: var(--Color-black, #000);
      font-family: "DM Sans";
      font-size: 0.875rem;
      font-style: normal;
      font-weight: 400;
      line-height: normal;
      &:hover {
          cursor: pointer;
          background: color-mix(in srgb, #000 15%, transparent);
      }
      &-red {
          border-radius: 62.5rem;
          border: 1px solid var(--Color-Red-red, #EC2227);
          background: var(--Color-Red-red20, #FFE3E4);
          color: var(--Color-Red-red120, #CA0006);
          font-family: "DM Sans";
          font-size: 0.875rem;
          font-style: normal;
          font-weight: 400;
          line-height: normal;
      }
    } 
  `,
  ],
})
export class DashboardPelacakKebijakanFilterTipeDokumenComponent
  implements OnInit
{
  isMenuOpened: boolean;
  filters: WritableSignal<any[]>;
  hasCheckedFilter: Signal<boolean>;
  checkedFiltersLength: Signal<number>;
  formatFirstCheckedFilter: Signal<string | undefined>;

  constructor(public router: Router, public activatedRoute: ActivatedRoute) {
    this.isMenuOpened = false;
    this.filters = signal([]);
    this.hasCheckedFilter = computed(() => false);
    this.checkedFiltersLength = computed(() => 0);
    this.formatFirstCheckedFilter = computed(() => undefined);
  }

  ngOnInit(): void {
    this.filters = this.initializeFilters(this.activatedRoute);
    this.hasCheckedFilter = computed(() =>
      this.filters().some((filter) => filter.checked)
    );
    this.checkedFiltersLength = computed(
      () => this.filters().filter((filter) => filter.checked).length
    );
    this.formatFirstCheckedFilter = computed(
      () => this.filters().find((filter) => filter.checked)?.type
    );
  }

  private initializeFilters(
    activatedRoute: ActivatedRoute
  ): WritableSignal<any[]> {
    const filters: WritableSignal<any[]> = signal([
      { type: 'Undang-Undang', checked: false },
      { type: 'Peraturan Pemerintah', checked: false },
      { type: 'Peraturan Presiden', checked: false },
      { type: 'Instruksi Presiden', checked: false },
      { type: 'Peraturan Menteri', checked: false },
      { type: 'Keputusan Menteri', checked: false },
    ]);

    const types = activatedRoute.snapshot.queryParamMap.getAll('type');
    if (types.length === 0) {
      return filters;
    }

    filters.update((value) => {
      const newValue = [...value];

      newValue.forEach((filter) => {
        filter.checked = types.includes(filter.type);
      });

      return newValue;
    });

    return filters;
  }

  toggleFilter(filters: WritableSignal<any[]>, type: any) {
    filters.update((value) => {
      const index = value.findIndex((filter) => filter.type === type);
      if (index === -1) {
        return value;
      }

      const newValue = [...value];

      const updatedFilter = { ...newValue[index] };
      updatedFilter.checked = !updatedFilter.checked;

      newValue[index] = updatedFilter;

      return newValue;
    });
  }
}

stackblitz