Creating Angular Components with Material Components Web
Creating Angular Components with Material Components Web

Creating Angular Components with Material Components Web

# Creating Banner: Advanced Approach We will again create similar banner component, but with a more advanced approach. Angular wind up targeting more than just a web browser. For such cases and for some highly advanced application architectures - a more robust approach is required. Material Component for Web provide foundations and adapters to accommodate this use case. > If you want, you can first read through [architecture overview](https://www.github.com/material-components/material-components-web/tree/v12.0.0/docs/code/architecture.md) in order to familiarize yourself with the general concepts behind them. Each Material component for web is accompanied by a corresponding foundation class, typically named `MDCComponentFoundation`, where `MDCComponent` represents the component's name. For instance, our `MDCBanner` component utilizes an `MDCBannerFoundation`, both of which are publicly exported. In general, to utilize a foundation for a component, we would follow these steps: 1. Incorporate the component's CSS on the webpage in any manner you prefer. 2. Append an instance property to your component that will be assigned to the appropriate foundation class. Let's name this `mdcFoundation`. 3. Create an instance of a foundation class, providing it with a suitably configured adapter as a parameter. 4. Upon initialization of your component, invoke `mdcFoundation.init()`. 5. When your component is terminated, invoke `mdcFoundation.destroy()`. Let's get started! ## 1. Create a component ```bash ng g c shared/components/banner-advanced ``` ## 2. Component template Taking example from previous chapter, we will write the template with below content: ```html
{{ text }}
{{ secondaryAction }}
{{ primaryAction }}
``` This template is just for starter, we will improve it with implementation. ## 3. Simple Banner implementation For starter, we will use the simple approach's implementation, then we will make changes: ```ts @Component({ selector: 'app-banner-advanced', standalone: true, imports: [CommonModule], templateUrl: './banner-advanced.component.html', }) export class BannerAdvancedComponent implements OnDestroy { @Input() primaryAction: string = ''; @Input() secondaryAction: string = ''; @Input({ required: true }) text!: string; @Input() centered: boolean = false; @Input() fixed: boolean = false; @Input() mobileStacked: boolean = false; banner!: MDCBanner; @ViewChild('mdcBanner') mdcBanner!: ElementRef; elementRef = inject>(ElementRef); mdcButtons: MDCRipple[] = []; constructor() { afterNextRender( () => { if (this.elementRef) { const element = this.elementRef.nativeElement.querySelector('.mdc-banner'); if (element) { // 1. Render banner this.banner = new MDCBanner(element); // 2. Render buttons element.querySelectorAll('.mdc-button').forEach((btnEle) => { this.mdcButtons.push(new MDCRipple(btnEle)); }); } } }, { phase: AfterRenderPhase.Write } ); } ngOnDestroy(): void { this.mdcButtons.forEach((btn) => btn.destroy()); this.banner?.destroy(); } open() { this.banner.open(); } close(reason: CloseReason = CloseReason.UNSPECIFIED) { this.banner.close(reason); } } ``` ## 4. Add `MDCBannerFoundation` and `MDCBannerAdapter` First, we will remove usage of `MDCBanner` and instead use `MDCBannerFoundation` and `MDCBannerAdapter`: ```ts private _foundation!: MDCBannerFoundation; get adapter(): MDCBannerAdapter { return { addClass: (className) => { // TBD }, getContentHeight: { // TBD }, notifyClosed: () => undefined, notifyClosing: () => undefined, notifyOpened: () => undefined, notifyActionClicked: () => undefined, notifyOpening: () => undefined, releaseFocus: () => { // TBD }, removeClass: (className) => { // TBD }, setStyleProperty: { // TBD }, trapFocus: () => { // TBD }, }; } constructor() { afterNextRender( () => { if (this.elementRef) { this._foundation = new MDCBannerFoundation(this.adapter); this._foundation.init(); } }, { phase: AfterRenderPhase.Write } ); } open() { this._foundation.open(); } close(reason: CloseReason = CloseReason.UNSPECIFIED) { this._foundation.close(reason); } ``` As you can see, we started using `MDCBannerAdapter` in constructor of `MDCBannerFoundation`. The idea behind adapter is providing custom implementation to `MDCBannerFoundation`, so that whenever that event is occurred, `MDCBannerFoundation` will perform function provided by us. You can relate this with JavaScript's `addEventListener` implementation. Now, we will not implement all the methods of `MDCBannerAdapter`, but just the needed ones. ### 4.1 `addClass()` We will maintain the classes needed in template separately and change them through `addClass` method of adapter. ```ts // outside component classNames = (classList: string[]) => classList.filter(Boolean).join(' '); // inside component classList = new Set(); get classes() { return classNames([ 'mdc-banner', ...Array.from(this.classList), this.centered ? 'mdc-banner--centered' : '', this.mobileStacked ? 'mdc-banner--mobile-stacked' : '', ]); } get adapter(): MDCBannerAdapter { return { addClass: (className) => { this.classList.add(className); }, // ... }; } ``` After above, we will remove all classes from root element in template and replace it with `[ngClass]="classes"`: ```html
``` ### 4.2 `removeClass` This will be opposite of `addClass`: ```ts get adapter(): MDCBannerAdapter { return { // ... removeClass: (className) => { this.classList.delete(className); }, // ... }; } ``` ### 4.3 `getContentHeight` `getContentHeight` needs a function which returns content's height. Som first we will expose the content element through template variable and then access it's height in component: ```html
``` ```ts @ViewChild('mdcBannerContent') mdcBannerContent!: ElementRef; getContentHeight = () => { return this.mdcBannerContent.nativeElement.offsetHeight; }; get adapter(): MDCBannerAdapter { return { // ... getContentHeight: this.getContentHeight, // ... }; } ``` ### 4.4 `trapFocus` This method is executed to trap focus in banner and to focus on the primary action button. We will use `FocusMonitor` module from `@angular/cdk/a11y`. Let's install is: ```bash npm i @angular/cdk ``` Next, we will use `cdkTrapFocus` in root element: ```html
{{ primaryAction }}
``` Now we will implement `trapFocus`: ```ts @ViewChild('primaryActionEl') primaryActionEl!: ElementRef; focusMonitor = inject(FocusMonitor); get adapter(): MDCBannerAdapter { return { // ... trapFocus: () => { // Trapping is taken care by cdkTrapFocus directive // We just need to focus on primary btn this.focusMonitor.focusVia( this.primaryActionEl.nativeElement, 'program' ); }, // ... }; } ``` ### 4.5 `releaseFocus` This method is called to release focus from banner and restore focus to the previously focused element. We will allow consumer to pass CSS selector of trigger in `open` method, and use the same to revert the focus when banner releases focus. We wil use `FocusMonitor.focusVia` to get the focus back on the trigger element: ```typescript private _document = inject(DOCUMENT); triggerSelector: string | undefined; open(triggerSelector?: string) { this._foundation.open(); this.triggerSelector = triggerSelector; } get adapter(): MDCBannerAdapter { return { // ... releaseFocus: () => { const ele = this.triggerSelector && (this._document.querySelector(this.triggerSelector) as HTMLElement); if (ele) { this.focusMonitor.focusVia(ele, 'program'); } this.triggerSelector = undefined; }, // ... }; } ``` ### 4.6 `setStyleProperty` We will maintain the styles needed in template separately and change them through `setStyleProperty` method of adapter. ```ts style = {}; setStyle = (varName: string, value: string) => { const updatedStyle: { [key: string]: string } = Object.assign( {}, this.style ); updatedStyle[varName] = value; this.style = updatedStyle; }; get adapter(): MDCBannerAdapter { return { // ... setStyleProperty: this.setStyle, // ... }; } ``` After above, we will use the `style` in template, too: ```html
``` ## 5. Click events As now we are not using `MDCBanner`, we will have to handle click events of primary and secondary action buttons. ```html
{{ secondaryAction }}
{{ primaryAction }}
``` Let's add `handleSecondaryActionClick()` and `handlePrimaryActionClick()` in component: ```ts @Input() autoClose = true; handlePrimaryActionClick() { this._foundation.handlePrimaryActionClick(!this.autoClose); } handleSecondaryActionClick() { this._foundation.handleSecondaryActionClick(!this.autoClose); } ``` ## 6. Change detection Let's try to use the banner: ```html Show advanced banner ``` And you would notice output like below: ![embedded video](https://res.cloudinary.com/dbgsyjnmu/video/upload/v1701080936/Untitled_video_-_Made_with_Clipchamp_10_gtvfne.mp4) Notice that layout changes are not working as expected. Note that all the style related changes are done through `MDCBannerFoundation` and `MDCBannerAdapter`. To fix that, we will use [`OnPush`](https://angular.io/api/core/ChangeDetectionStrategy#OnPush) strategy with [`ChangeDetectorRef`](https://angular.io/api/core/ChangeDetectorRef)'s `detach()` and `detectChanges()` methods. ```ts @Component({ // ... changeDetection: ChangeDetectionStrategy.OnPush, }) export class BannerAdvancedComponent { cdr = inject(ChangeDetectorRef); constructor() { this.cdr.detach(); // rest remains same } setStyle = (varName: string, value: string) => { // ... this.cdr.detectChanges(); }; get adapter(): MDCBannerAdapter { return { addClass: (className) => { this.classList.add(className); this.cdr.detectChanges(); }, // ... removeClass: (className) => { this.classList.delete(className); this.cdr.detectChanges(); }, // ... }; } } ``` Let's check the result now: ![embedded video](https://res.cloudinary.com/dbgsyjnmu/video/upload/v1701082021/Untitled_video_-_Made_with_Clipchamp_11_dqncvp.mp4) It's working as expected now! ## 7. Theming We can also add support for color theming easily using SASS mixins. You can learn more about them in [official docs](https://m2.material.io/components/banners/web#api). Let's modify `src/styles/_banner.scss`: ```scss // src/styles/_banner.scss @use '@material/theme/theme-color'; @use '@material/banner/styles'; @use '@material/button/styles' as mdcButtonStyles; @use '@material/banner/banner-theme'; .color { &-accent { $theme: ( action-focus-state-layer-color: theme-color.$accent, action-hover-state-layer-color: theme-color.$accent, action-label-text-color: theme-color.$accent, action-pressed-state-layer-color: theme-color.$accent ); @include banner-theme.graphic-background-color(theme-color.$accent); .mdc-banner { @include banner-theme.theme-styles($theme); } } &-warn { $theme: ( action-focus-state-layer-color: theme-color.$error, action-hover-state-layer-color: theme-color.$error, action-label-text-color: theme-color.$error, action-pressed-state-layer-color: theme-color.$error, ); @include banner-theme.graphic-background-color(theme-color.$error); .mdc-banner { @include banner-theme.theme-styles($theme); } } } ``` Now, let's add support for `color` in component: ```ts @Input() color: 'primary' | 'accent' | 'warn' = 'primary'; @HostBinding('class.color-accent') get accentColor() { return this.color === 'accent'; } @HostBinding('class.color-warn') get warnColor() { return this.color === 'warn'; } ``` Now, if you pass `color="accent"` or `color="warn"`, you will see respective themes applied. ![primary](https://res.cloudinary.com/dbgsyjnmu/image/upload/v1701082810/banner-primary_wrzgvw.png) ![accent](https://res.cloudinary.com/dbgsyjnmu/image/upload/v1701082809/banner-accent_odtxml.png) ![warn](https://res.cloudinary.com/dbgsyjnmu/image/upload/v1701082810/banner-warn_n9s0rl.png) ## 8. Full code ### 8.1 HTML Template ```html
{{ text }}
{{ secondaryAction }}
{{ primaryAction }}
``` ### 8.2 Component ```ts import { Component, ElementRef, Input, ViewChild, inject, ChangeDetectionStrategy, ChangeDetectorRef, HostBinding, afterRender, OnDestroy, AfterRenderPhase, afterNextRender, } from '@angular/core'; import { CommonModule, DOCUMENT } from '@angular/common'; import { MDCBanner, CloseReason, MDCBannerAdapter, MDCBannerFoundation, } from '@material/banner'; import { MDCRipple } from '@material/ripple'; import { A11yModule, FocusMonitor } from '@angular/cdk/a11y'; export const classNames = (classList: string[]) => classList.filter(Boolean).join(' '); @Component({ selector: 'app-banner-advanced', standalone: true, imports: [CommonModule, A11yModule], templateUrl: './banner-advanced.component.html', changeDetection: ChangeDetectionStrategy.OnPush, }) export class BannerAdvancedComponent implements OnDestroy { @Input() primaryAction = ''; @Input() secondaryAction = ''; @Input({ required: true }) text!: string; @Input() centered = false; @Input() fixed = false; @Input() mobileStacked = false; @Input() icon = ''; @Input() autoClose = true; @Input() color: 'primary' | 'accent' | 'warn' = 'primary'; banner!: MDCBanner; @ViewChild('mdcBannerContent') mdcBannerContent!: ElementRef; @ViewChild('primaryActionEl') primaryActionEl!: ElementRef; elementRef = inject>(ElementRef); mdcButtons: MDCRipple[] = []; private _document = inject(DOCUMENT); private _foundation!: MDCBannerFoundation; classList = new Set(); style = {}; focusMonitor = inject(FocusMonitor); cdr = inject(ChangeDetectorRef); triggerSelector: string | undefined; get classes() { return classNames([ 'mdc-banner', ...Array.from(this.classList), this.centered ? 'mdc-banner--centered' : '', this.mobileStacked ? 'mdc-banner--mobile-stacked' : '', ]); } constructor() { this.cdr.detach(); afterNextRender( () => { if (this.elementRef) { this._foundation = new MDCBannerFoundation(this.adapter); this._foundation.init(); } }, { phase: AfterRenderPhase.Write } ); } @HostBinding('class.color-accent') get accentColor() { return this.color === 'accent'; } @HostBinding('class.color-warn') get warnColor() { return this.color === 'warn'; } setStyle = (varName: string, value: string) => { const updatedStyle: { [key: string]: string } = Object.assign( {}, this.style ); updatedStyle[varName] = value; this.style = updatedStyle; this.cdr.detectChanges(); }; getContentHeight = () => { return this.mdcBannerContent.nativeElement.offsetHeight; }; get adapter(): MDCBannerAdapter { return { addClass: (className) => { this.classList.add(className); this.cdr.detectChanges(); }, getContentHeight: this.getContentHeight, notifyClosed: () => undefined, notifyClosing: () => undefined, notifyOpened: () => undefined, notifyActionClicked: () => undefined, notifyOpening: () => undefined, releaseFocus: () => { const ele = this.triggerSelector && (this._document.querySelector(this.triggerSelector) as HTMLElement); if (ele) { this.focusMonitor.focusVia(ele, 'program'); } this.triggerSelector = undefined; }, removeClass: (className) => { this.classList.delete(className); this.cdr.detectChanges(); }, setStyleProperty: this.setStyle, trapFocus: () => { // Trapping is taken care by cdkTrapFocus directive // We just need to focus on primary btn this.focusMonitor.focusVia( this.primaryActionEl.nativeElement, 'program' ); }, }; } // ngAfterViewInit(): void { // if (this.elementRef) { // this._foundation = new MDCBannerFoundation(this.adapter); // this._foundation.init(); // } // } ngOnDestroy(): void { this._foundation?.destroy(); } open(triggerSelector?: string) { this._foundation.open(); this.triggerSelector = triggerSelector; } close(reason: CloseReason = CloseReason.UNSPECIFIED) { this._foundation.close(reason); } handlePrimaryActionClick() { this._foundation.handlePrimaryActionClick(!this.autoClose); } handleSecondaryActionClick() { this._foundation.handleSecondaryActionClick(!this.autoClose); } } ```
Support Free Content Creation

Contributions & Support

Even though the courses and articles are available at no cost, your support in my endeavor to deliver top-notch educational content would be highly valued. Your decision to contribute aids me in persistently improving the course, creating additional resources, and maintaining the accessibility of these materials for all. I'm grateful for your consideration to contribute and make a meaningful difference!

Envelop
Don't miss any update

Stay up to date

Subscribe to the newsletter to stay up to date with articles, courses and much more!

Angular Material Dev

Angular Material Dev is one place stop for developers to learn about integrating Material Design in Angular applications like a pro.

Find us on X (Twitter) and LinkedIn