# 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
{{ 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
```
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
{{ 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);
}
}
```