Angular directives for CSS3 Grid
CSS3 Grid and Flex
CSS3 provides couple of powerful concepts to create layouts: Grid and Flex. The former uses a top to bottom approach and later uses the reverse. Both these concepts are well supported by browsers except IE11. Though there are advantages in leveraging CSS to accomplish layouts (for example, we can make them responsive through media queries) there are benefits in leveraging a HTML like declarative approach. Angular provides a powerful concept called directives that helps to enhance HTML by adding custom behaviors to it.
In this post, I'm gonna show you how we can create couple of directives that helps to craft layouts using CSS3 grid. As a bonus, I also show you how we can make the layouts responsive. I'm also glad to introduce you the library angular-bone which is a real world implementation of this sample that supports both flex and grid layouts.
Angular 6 Directives
Angular 6 provides two types of directives.
- Attribute directives
- Structural directives
Attribute directives helps to modify the appearance or behavior of DOM. Structural directives helps to modify the structure of DOM. Our directives are gonna apply inline styles to the elements (remember they are based on CSS3 Grid) and not gonna change the structure, so we are gonna create them as attribute-based ones.
CSS3 Grid
CSS3 Grid is a long waited feature that helps to create two-dimensional layouts. Unlike flex, in grid you can control the position and spanning of items in both row and column directions. It's more like defining a matrix and then specifying the position of items in the respective cells. Through media queries you can change the matrix and thereby changing the position of all items.
Let's take a simple blog layout that contains header, sidebar, main area and footer.
<div class="container"> <header>HEADER</header> <aside>SIDEBAR</aside> <main>CONTENT</main> <footer>FOOTER</footer> </div>
Listing 1. Simple blog layout
Assume you want the header to span the entire first row, the sidebar and content has to share the second row (sidebar - 25%), the footer has to span the entire third row. If you define the sentence in matrix this is how it looks like,
3 x 2 matrix layout
h h
a m
f f
h - header
a - aside
m - content
f - footer
In CSS3, instead of matrix we call it as "grid areas". The exact CSS property name for it is called as grid-template-areas. There are quite lot of properties are there to specify the rows, columns, alignment etc. Please go through this guide to learn quick about CSS3 Grid.
Coming back to our example, below is the complete CSS code required to define the layout using CSS3 grid.
.container { display: grid; grid-template-areas: "h h" "a m" "f f"; grid-template-columns: 25% 1fr; grid-template-rows: auto 1fr auto; } header { grid-area: h; } aside { grid-area: a; } main { grid-area: m; } footer { grid-area: f; }
Listing 2. Applying CSS3 Grid styles
We can also define the same layout using other properties (without using grid-template-areas. The advantage of using areas is, if you want to adjust the layout for different screen sizes you can easily do it changing only the grid-template-areas of the grid container without modifying the properties of each grid item.
CSS3 Grid directives
We need to create two directives to build CSS3 grid layouts. One directive to configure the grid container (css-g) and other one to configure a grid item (css-gi) Using these directives we can rewrite the above example as below.
<div class="container" css-g css-g-areas="'h h' 'a m' 'f f'" css-g-rows="auto 1fr auto" css-g-cols="25% 1fr'> <header css-gi css-gi-area="h">HEADER</header> <aside css-gi css-gi-area="a">SIDEBAR</aside> <main css-gi css-gi-area="m">CONTENT</main> <footer css-gi css-gi-area="f">FOOTER</footer> </div>
Listing 3. Applying CSS3 grid directives
Let's see how we can build these directives.
Create a new angular app by running the below command,
ng new css-grid-directives
Listing 4. Creating an angular application with CLI
We need to do some configurations before getting into coding.
- Open the tslint.json file in the root and set both the "no-input-rename" and "no-output-rename" properties to false.
- Open the tsconfig.json file and set the "downlevelIteration" to true.
- Open the tslint.json file under the "src" folder and change the "directive-selector" from "camelCase" to "kebab-case".
Let's go ahead and create a new folder under "src" called "shared". We are gonna create our directives here.
Grid Container Directive (css-g)
Create a new file with name "grid.container.directive.ts" under the "shared" folder. This directive helps to configure the properties for a grid container.
Let's take a moment and list out the things we've to do in this directive.
- There are lot of properties provided by CSS3 grid to configure a container but we'll use only three: grid-template-areas, grid-template-columns and grid-template-rows. For real-world use please try out angular-bone that provides support for all the properties.
- We also need a property to specify whether to use a normal or inline grid.
- Based on the values of the properties we need to set the styles inline to the element. We can do that in the ngOnChanges life cycle hook which will be called during initialization as well as whenever any of the property changes.
- When the directive get destroyed we need to remove the styles set by it to the element. We can do this by tapping to the ngOnDestroy life cycle hook.
Below is the complete code of the container directive.
import {Directive, ElementRef, Input, OnChanges, OnDestroy} from '@angular/core'; @Directive({ selector: '[css-g]' }) export class GridContainerDirective implements OnChanges, OnDestroy { private _display = 'grid'; public get display(): string { return this._display; } @Input('css-g') public set display(value: string) { this._display = value || 'grid'; } @Input('css-g-areas') public areas: string; @Input('css-g-cols') public cols: string; @Input('css-g-rows') public rows: string; constructor(private el: ElementRef) { } public ngOnChanges() { const nativeElement = this.el.nativeElement; this.clearStyles(nativeElement); if (this.display) { nativeElement.style.setProperty('display', this.display); } if (this.areas) { nativeElement.style.setProperty('grid-template-areas', this.areas); } if (this.cols) { nativeElement.style.setProperty('grid-template-columns', this.cols); } if (this.rows) { nativeElement.style.setProperty('grid-template-rows', this.rows); } } public ngOnDestroy() { this.clearStyles(this.el.nativeElement); } private clearStyles(el) { el.style.removeProperty('display'); el.style.removeProperty('grid-template-areas'); el.style.removeProperty('grid-template-cols'); el.style.removeProperty('grid-template-rows'); } }
Listing 5. Basic implementation of grid container directive
Grid Item Directive (css-gi)
Create a new file with name "grid.item.directive.ts". This directive helps to configure the properties of a grid item. For this article we'll support only three properties: grid-column, grid-row and grid-area The list of things we've to do is same as the container directive.
import {Directive, ElementRef, Input, OnChanges, OnDestroy} from '@angular/core'; @Directive({ selector: '[css-gi]' }) export class GridItemDirective implements OnChanges, OnDestroy { @Input('css-gi-area') public area: string; @Input('css-gi-col') public col: string; @Input('css-gi-row') public row: string; constructor(private el: ElementRef) { } public ngOnChanges() { const nativeElement = this.el.nativeElement; this.clearStyles(nativeElement); if (this.area) { nativeElement.style.setProperty('grid-area', this.area); } if (this.row) { nativeElement.style.setProperty('grid-row', this.row); } if (this.col) { nativeElement.style.setProperty('grid-column', this.col); } } public ngOnDestroy() { this.clearStyles(this.el.nativeElement); } private clearStyles(el) { el.style.removeProperty('grid-area'); el.style.removeProperty('grid-row'); el.style.removeProperty('grid-column'); } }
Listing 6. Basic implementation of grid item directive
We need to include package these directives in a module. Create a new file "module.ts" file under the "shared" folder. In the shared module, declare and export our directives.
import {NgModule} from '@angular/core'; import {CommonModule} from '@angular/common'; import {GridContainerDirective} from './grid.container.directive'; import {GridItemDirective} from './grid.item.directive'; @NgModule({ imports: [ CommonModule ], declarations: [ GridContainerDirective, GridItemDirective ], exports: [ GridContainerDirective, GridItemDirective ] }) export class SharedModule { }
Listing 7. Packaging grid directives in a shared module
Let put our directives to test!
Test Drive
We are gonna create the same blog layout shown in listing x. using our directives. Open the the "app.module.ts" file under the "app" folder and import the SharedModule.
import {SharedModule} from '../shared/module'; @NgModule({ declarations: [ AppComponent ], imports: [ BrowserModule, SharedModule ], providers: [], bootstrap: [AppComponent] }) export class AppModule { }
Listing 8. Importing SharedModule in AppModule
Open the "app.component.html" file and replace the existing markup with below.
<div class="container" css-g css-g-areas="'h h' 'a m' 'f f'" css-g-cols="25% 1fr" css-g-rows="auto 1fr auto"> <header css-gi css-gi-area="h"> Header </header> <aside css-gi css-gi-area="a"> Aside </aside> <main css-gi css-gi-area="m"> Main </main> <footer css-gi css-gi-area="f"> Footer </footer> </div>
Listing 9. Applying grid directives to our layout
Let's add some styles to the elements. Open the "app.component.css" and paste the below code.
.container { height: 100vh; font-size: 1.5rem; } header { height: 60px; background-color: #a07df2; } aside { background-color: #c7e853; } main { background-color: #f2ef37; } footer { background-color: #ff2873; }
Listing 10. Applying some CSS styles to our elements
Go to the terminal and run the npm start command. If you open http://localhost:4200 in your browser you should see the below screen.
Enhancing directives for responsiveness
As a final thing, I'm gonna show how we can make these directives responsive to screen sizes. To achieve this we need to use the window.watchMedia method. The window.watchMedia takes a set of media queries and invoke the callback registered to it when there is a change in the breakpoint. You can read more about window.watchMedia here.
In our Angular app, let's create a service to do this work. The service is gonna be a singleton and any part of the application that needs to listen to breakpoint changes can use this service. We are gonna store the list of callback in an array and invoke them when there is a breakpoint change.
Create a new file called "watcher.ts" under the shared folder and drop the below code.
import {Inject, Injectable, NgZone} from '@angular/core'; @Injectable({ providedIn: 'root' }) export class MediaSizeWatcher { private currentMediaSize: string; private subscribers: Array<(MediaQueryList) => void> = []; private mediaSizeQueryMap: Map<string, MediaQueryList> = new Map<string, MediaQueryList>([ ['lg', window.matchMedia('(min-width: 768px)')], ['sm', window.matchMedia('(min-width: 0)')] ]); constructor(@Inject(NgZone) private ngZone: NgZone) { this.listen = this.listen.bind(this); this.mediaSizeQueryMap.forEach((value: MediaQueryList) => value.addListener(this.listen)); this.listen(); } public getCurrentMedia(): string { return this.currentMediaSize; } public watch(subscriber: (MediaQueryList) => void): () => void { this.subscribers.push(subscriber); return () => this.subscribers.splice(this.subscribers.length - 1, 1); } private listen(): void { for (const mediaSizeQuery of this.mediaSizeQueryMap) { const [mediaSize, query] = mediaSizeQuery; if (query.matches) { if (mediaSize !== this.currentMediaSize) { this.currentMediaSize = mediaSize; this.alertSubscribers(); } break; } } } private alertSubscribers(): void { this.ngZone.run(() => { this.subscribers.forEach(subscriber => subscriber(this.currentMediaSize)); }); } }
Listing 11. Service that watch for breakpoint changes
I would like to point out couple of things here. First, we are only watching for two screen sizes: small (below 768px) and large (above 768px and above), ideally we need to watch for multiple screen sizes as angular-bone does. Next, we are using NgZone to make sure Angular change detection works when the callbacks are invoked.
Let's import the service in the shared module.
import {MediaSizeWatcher} from './watcher'; @NgModule({ providers: [ MediaSizeWatcher ], ... })
Listing 12. Importing MediaSizeWatcher service in SharedModule
Now we need to modify our grid directives to expose additional properties to accept values for both screen sizes and apply the correct set based on the breakpoint information provided by the service. Below is the updated code for both the directives.
grid.container.directive.ts
import {Directive, ElementRef, Input, OnChanges, OnDestroy} from '@angular/core'; import {MediaSizeWatcher} from './watcher'; @Directive({ selector: '[css-g]' }) export class GridContainerDirective implements OnChanges, OnDestroy { private _display = 'grid'; public get display(): string { return this._display; } @Input('css-g') public set display(value: string) { this._display = value || 'grid'; } private _displayLg = 'grid'; public get displayLg(): string { return this._displayLg; } @Input('css-g-lg') public set displayLg(value: string) { this._displayLg = value || 'grid'; } @Input('css-g-areas') public areas: string; @Input('css-g-areas-lg') public areasLg: string; @Input('css-g-cols') public cols: string; @Input('css-g-cols-lg') public colsLg: string; @Input('css-g-rows') public rows: string; @Input('css-g-rows-lg') public rowsLg: string; public currentMediaSize: string; private watcherUnSubscribeFn = () => {}; constructor(private el: ElementRef, private watcher: MediaSizeWatcher) { this.currentMediaSize = this.watcher.getCurrentMedia(); this.watcherUnSubscribeFn = this.watcher.watch((mediaSize: string) => { this.currentMediaSize = mediaSize; this.ngOnChanges(); }); } public ngOnChanges() { const nativeElement = this.el.nativeElement; this.clearStyles(nativeElement); const display = this.currentMediaSize === 'lg' ? this.displayLg || this.display : this.display; if (display) { nativeElement.style.setProperty('display', display); } const areas = this.currentMediaSize === 'lg' ? this.areasLg || this.areas : this.areas; if (areas) { nativeElement.style.setProperty('grid-template-areas', areas); } const cols = this.currentMediaSize === 'lg' ? this.colsLg || this.cols : this.cols; if (cols) { nativeElement.style.setProperty('grid-template-columns', cols); } const rows = this.currentMediaSize === 'lg' ? this.rowsLg || this.rows : this.rows; if (rows) { nativeElement.style.setProperty('grid-template-rows', rows); } } public ngOnDestroy() { this.watcherUnSubscribeFn(); this.clearStyles(this.el.nativeElement); } private clearStyles(el) { el.style.removeProperty('display'); el.style.removeProperty('grid-template-areas'); el.style.removeProperty('grid-template-cols'); el.style.removeProperty('grid-template-rows'); } }
Listing 13. Grid container directive
grid.item.directive.ts
import {Directive, ElementRef, Input, OnChanges, OnDestroy} from '@angular/core'; import {MediaSizeWatcher} from './watcher'; @Directive({ selector: '[css-gi]' }) export class GridItemDirective implements OnChanges, OnDestroy { @Input('css-gi-area') public area: string; @Input('css-gi-area-lg') public areaLg: string; @Input('css-gi-col-lg') public colLg: string; @Input('css-gi-col') public col: string; @Input('css-gi-row-lg') public rowLg: string; @Input('css-gi-row') public row: string; public currentMediaSize: string; private watcherUnSubscribeFn = () => {}; constructor(private el: ElementRef, private watcher: MediaSizeWatcher) { this.currentMediaSize = this.watcher.getCurrentMedia(); this.watcherUnSubscribeFn = this.watcher.watch((mediaSize: string) => { this.currentMediaSize = mediaSize; this.ngOnChanges(); }); } public ngOnChanges() { const nativeElement = this.el.nativeElement; this.clearStyles(nativeElement); const area = this.currentMediaSize === 'lg' ? this.areaLg || this.area : this.area; if (area) { nativeElement.style.setProperty('grid-area', area); } const row = this.currentMediaSize === 'lg' ? this.rowLg || this.row : this.row; if (row) { nativeElement.style.setProperty('grid-row', row); } const col = this.currentMediaSize === 'lg' ? this.colLg || this.col : this.col; if (col) { nativeElement.style.setProperty('grid-column', col); } } public ngOnDestroy() { this.watcherUnSubscribeFn(); this.clearStyles(this.el.nativeElement); } private clearStyles(el) { el.style.removeProperty('grid-area'); el.style.removeProperty('grid-row'); el.style.removeProperty('grid-column'); } }
Listing 14. Grid item directive
Our directives are baked! To test the responsiveness, let's make our simple blog layout responsive. Currently our layout is a two column layout for all screen sizes let's make it as single column for small screen sizes. Open the "app.component.html" file and change the markup as below.
<div class="container" css-g css-g-areas="'h' 'a' 'm' 'f'" css-g-areas-lg="'h h' 'a m' 'f f'" css-g-cols="1fr" css-g-cols-lg="25% 1fr" css-g-rows="auto auto 1fr auto" css-g-rows-lg="auto 1fr auto"> <header css-gi css-gi-area="h"> Header </header> <aside css-gi css-gi-area="a"> Aside </aside> <main css-gi css-gi-area="m"> Main </main> <footer css-gi css-gi-area="f"> Footer </footer> </div>
Listing 15. Applying changes to our layout to make it responsive
If you go back to the browser and reduce the window size the grid should adjust to a single column as shown below.
Hope you enjoyed and learnt something new. Please share your questions and feedback through comments which are so much to me. For real-world implementation, please check out angular-bone project. It provides you directives for both flex and grid layouts. It also supports you five different screen sizes. Please go ahead and give a star for the repo in github and please feel free to fork and meddle with the code!