Responsive Design

The other aspect this tutorial covers is responsive design. The main concept behind responsive design is to have a single HTML representation, which uses CSS to dynamically adapt the display to different screen sizes. The majority of these changes is possible via CSS, but later on in the responsive re-design we will also look at turning on and off features based on screen size using JavaScript.

When implementing responsive design the primary paradigm is what is known as “mobile first”. This means that we first design the layout of each page with display on a mobile device. Then we add the necessary CSS classes and CSS code to adapt the layout to those displays where we have more space available. While we could also do it in the opposite direction, building mobile-first results in interface designs that adapt better to the different screen sizes.

Testing on mobile devices is difficult, as there are so many different device sizes. Luckily, modern browsers all provide an environment for simulating other device sizes. In all of them you access this via the developer tools (usually accessed via the F12 key). There, the icon for responsive design tends to look something like this:

Screenshot of the Firefox responsive design button

Firefox: The responsive design button is the icon that looks a bit like two different sized mobile devices (third from the right)

Screenshot of the Firefox responsive design button

Chrome: The responsive design button is the icon that looks a bit like two different sized mobile devices (second from the left)

Click on the icon to turn responsive design mode on or off. When responsive design mode is on, you can resize the display area to any dimension you like or via the toolbar at the top, you can select device sizes from a pre-defined list.

Basic CSS Adaptation

Before we can start to implement the responsive design, we need to tidy up our classes. First, update app/templates/application.hbs to:

{{page-title 'Athena'}}
<div class='container-fluid gx-0'>
    <NavBar />
    {{outlet}}
</div>

Next, update app/templates/modules/index.hbs to:

{{page-title 'Modules'}}
<main class='row gx-0'>
    <div>
        <Modules::ModulesNavbar />
    </div>
    <div>
        <Modules::ModulesList @modules={{this.modules}} />
    </div>
</main>

Finally, we need to delete any col-lg or col-md classes in the components app/components/modules/modules-list.hbs and app/components/modules/modules-navbar.hbs. With that done, it is time to work on the responsive design. The class gx-0 removes the gutters that bootstrap would create otherwise which would let the background color show through our elements.

If you activate responsive design mode and set the size to a phone-size and then navigate to the list of modules, you will see that the design really does not work and requires horizontal scrolling, something that should always be avoided on mobile devices. Taking a mobile-first approach, on a small screen, we really want to have the menu displayed above the table. To do that, update app/templates/modules/index.hbs to:

{{page-title 'Modules'}}
<main class='row gx-0'>
    <div class='col-sm-12'>
        <Modules::ModulesNavbar />
    </div>
    <div class='col-sm-12'>
        <Modules::ModulesList @modules={{this.model}} />
    </div>
</main>

While up to this point we have let the browser decide how wide to make the menu, we are now explicitly giving it a width. Most modern CSS frameworks use a grid structure to define grids, with 12 columns per grid. Here, by specifying col-sm-12 we state that we want this element to have 12 out of 12 columns of width (effectively the full width of the page). As both the ``<div>``have their width set to the full width of the page, they cannot both fit onto the same line, thus the browser wraps the second element into a new line. This already looks much better, although the table still requires scrolling, which we will fix a bit later.

We will look at how these two elements should behave for larger screen sizes, first. If you increase the width of the display area, you will see that as we go wider, there is quite a bit of wasted space next to the menu and in the table. This is because any layout defined at the “small” scale is also applied to larger scales. We thus need to add extra CSS classes to override the small layout at larger sizes:

{{page-title 'Modules'}}
<main class='row gx-0'>
    <div class='col-sm-12 col-md-3'>
        <Modules::ModulesNavbar />
    </div>
    <div class='col-sm-12 col-md-9'>
        <Modules::ModulesList @modules={{this.model}} />
    </div>
</main>

By adding the two medium-X classes, we now provide a slightly different layout for medium-sized screens. The first column of the grid is set to be 3 columns wide (25%), while the second one is 9 columns (75%). As this now fits into a single 12-column row, both will be displayed next to each other, with the respective relative sizes. If you now resize the display window, you will see that when the width hits a certain point, the layout changes from the small vertical layout to the medium-size horizontal one. If you increase the size to the full width, then you will see that again we get excessive white-space, thus add additional CSS classes:

<div class="col-sm-12 col-md-3 col-lg-shrink">
  ...
</div>
<div class="col-sm-12 col-md-9 col-lg">
...

For the final large layout we don’t use specific widths, we tell the browser that the menu area should take up only as much space as it needs for its content (shrink it down to what is needed) and the table area should take up all remaining space. If you now play with the screen width, you will see that the layout adapts nicely to the full range of screen sizes.

Custom CSS Adaptation

However, at the small size, the table still exceeds the available width. To address this we will have to add some custom CSS in the app/styles/app.scss:

/*
 * Responsive Table
 */
@include media-breakpoint-down(md) {
    table thead {
        display: none;
    }
    table tbody td {
        display: block;
        width: 100%;
    }
}

When we adapted the layout using the CSS classes, we used three different levels: small, medium, and large. In CSS parlance these are generally known as breakpoints and we can use them when we create our own table-specific CSS. The Bootstrap CSS framework we are using provides some helper functions for dealing with these breakpoints in the form of the `media-breakpoint-only, media-breakpoint-down or media-breakpoint-up mixin function. This first function takes a single parameter, that defines which breakpoint the CSS rules contained within the mixin are to be applied to. In this case we specify that they should apply to the breakpoints below md and should not be used for the medium, large or even larger breakpoints. That is because for those sizes we want to show the table normally.

The two modifications we make for the small size are that we first hide the table header and then in the table body we set each table cell (the <td> elements) to be displayed as a block element with a width of 100%. The effect of this is that at the small size the table is reformatted from the horizontal layout into a vertical layout which works much better on small devices.

The vertical display is a significant improvement, but on a small display there might still be too much information that is perhaps not needed at that point. To address this we can then add some more CSS classes into the template to hide things that perhaps are not so necessary in the small display:

<td class="d-sm-none d-md-block">
    <LinkTo @route="modules.view" @model={{module.id}}>{{module.code}}</LinkTo>
</td>

JavaScript Adaptation

The final adaptation we want to make to the list of modules is to change the direction of the menu. For the small breakpoint we want the menu to be horizontal, but for all other sizes we want it to be vertical. To implement this we will create our own media query service:

$ yarn ember generate service media-query

We will only build a basic media query service that distinguishes small from not-small breakpoints. Update the code in app/services/media-query.js to this:

import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class MediaQueryService extends Service {
    @tracked breakpoint = 'sm';

    get small() {
        return this.breakpoint === 'sm';
    }

    get notSmall() {
        return this.breakpoint !== 'sm';
    }
}

The service has only one property, the breakpoint which we initialise to 'sm', due to the mobile-first approach. Then we define two getters small and notSmall. The first returns that the breakpoint value is 'sm', the second that it is not.

With that in place, we now need to actually determine what the current breakpoint is. To do this we will implement the init function and there use the window object’s innerWidth property to determine how many pixels wide the browser window is wide. The Bootstrap CSS framework by default uses 768px as the boundary between small and medium and 992px as the boundary between medium and large. Thus we do that as well, by adding the following code:

import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class MediaQueryService extends Service {
    @tracked breakpoint = 'sm';

    get small() {
        return this.breakpoint === 'sm';
    }

    get notSmall() {
        return this.breakpoint !== 'sm';
    }

    init() {
        super.init(...arguments);
        if(window.innerWidth >= 992) {
            this.breakpoint = 'lg'
        } else if (window.innerWidth >= 768 ) {
            this.breakpoint = 'md'
        } else {
            this.breakpoint = 'sm'
        }
    }
}

Warning

Should you use Visual Studio Code your linter might highlight the entire init-function as an error since the service is not marked as classic. Since this tutorial has been rewritten from an older version of Ember, I left this code mostly as it was and just modified it to the actual Ember version. Despite the thrown error, the code should work.

This will initialise the breakpoint correctly, but will not react to changes in the window’s width. To handle these changes, we add an event listener to listen for the resize event. When the event fires, we update the breakpoint:

import Service from '@ember/service';
import { tracked } from '@glimmer/tracking';

export default class MediaQueryService extends Service {
    @tracked breakpoint = 'sm';

    get small() {
        return this.breakpoint === 'sm';
    }

    get notSmall() {
        return this.breakpoint !== 'sm';
    }

    init() {
        super.init(...arguments);
        if(window.innerWidth >= 992) {
            this.breakpoint = 'lg'
        } else if (window.innerWidth >= 768 ) {
            this.breakpoint = 'md'
        } else {
            this.breakpoint = 'sm'
        }
        window.addEventListener('resize', () => {
            if(window.innerWidth >= 992) {
                this.breakpoint = 'lg'
            } else if (window.innerWidth >= 768 ) {
                this.breakpoint = 'md'
            } else {
                this.breakpoint = 'sm'
            }
        })
    }
}

With the service in place, we can now use our new service in the component class for the modules navbar, that we need to create first by running:

$ yarn ember generate component-class modules/modules-navbar

and then update app/components/modules/modules-navbar.js:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';

export default class ModulesModulesNavbarComponent extends Component {
    @service mediaQuery;

    get actionMenuDirection() {
        if(this.mediaQuery.breakpoint === 'sm') {
            return 'horizontal'
        } else {
            return 'vertical'
        }
    }
}

The new actionMenuDirection property now reacts to the value of the small property on our service and either returns 'horizontal' if we are in the small breakpoint, otherwise it is 'vertical'. These classes do not provide anything right now, so we need to update our app/styles/app.scss file:

/*
 * Responsive menu
 */

 .horizontal {
    flex-direction: row;
 }

 .vertical {
    flex-direction: column;
 }

With that in place, we can now use the computed property in our app/components/modules/modules-navbar.hbs to change the direction of the menu:

...
<ul class={{this.actionMenuDirection}} role='menu' aria-label='Modules'>
    ...
</ul>

If you now change the size of the window, you will see that on the small size the menu switches to a horizontal direction.

Responsive Main Navigation

The final modification we will do is to add a responsive main application menu. On the small screen the main application menu takes up a lot of space, but is only needed occasionally. We will thus use our service so that when we are in the small size, there is a small title bar with a menu icon to show the full menu. Update the app/components/nav-bar.hbs to this:

{{#if this.mediaQuery.small}}
<div class='row'>
    <div class='navbar navbar-dark bg-dark' data-responsive-toggle='responsive-menu' data-hide-for='md'>
        <span class='navbar-brand'>{{t 'application.menu'}}</span>
        <button class='navbar-toggler' type='button' aria-label='Show Main Menu' data-toggle='collapse' {{on 'click' this.toggleMenu}}>
            <span class='navbar-toggler-icon'></span>
        </button>
    </div>
</div>
{{/if}}

{{#if (or this.mediaQuery.notSmall this.menuVisible)}}
<header class='row'>
    <nav class='navbar navbar-dark bg-dark' aria-label='Main'>
        <ul class='navbar-nav {{this.menuDirection}}' role='menu' aria-label='Main'>
            <li class='navbar-brand'>
                <LinkTo @route='application' class='navbar-brand'>{{t 'application.athena_study_portal'}}</LinkTo>
            </li>
            <li>
                <LinkTo @route='modules' role='menuitem' tabindex='0' class='nav-link'>{{t 'application.my_modules'}}</LinkTo>
            </li>
            <li>
                <a class="nav-link" href='' role='menuitem' tabindex='-1'>{{t 'application.my_exams' }}</a>
            </li>
        </ul>
        <ul class='navbar-nav {{this.menuDirection}}' role='menu' aria-label='User'>
            {{#if (eq this.intl.locale.[0] 'en-us')}}
                <li><a {{action 'setLocale'}} class='nav-link active' role='menuitem' tabindex='0' aria-current='true'>English</a></li>
            {{else}}
                <li><a {{action 'setLocale'}} class='nav-link' role='menuitem' tabindex='0'>English</a></li>
            {{/if}}
            {{#if (eq this.intl.locale.[0] 'de-de')}}
                <li><a {{action 'setLocale' 'de-de'}} class='nav-link active' role='menuitem' tabindex='0' aria-current='true'>Deutsch</a></li>
            {{else}}
                <li><a {{action 'setLocale' 'de-de'}} class='nav-link' role='menuitem' tabindex='0'>Deutsch</a></li>
            {{/if}}
            <li role='separator'></li>
            <li><LinkTo @route='users.register' class='nav-link' tabindex='0'>{{t 'application.register'}}</LinkTo></li>
            <li><LinkTo @route='login' class='nav-link' role='menuitem' tabindex='-1'>{{t 'application.logout'}}</LinkTo></li>
        </ul>
    </nav>
</header>
{{/if}}

The main changes are the new title bar, the menuVisible switch around the <header>, and also the this.menuDirection for the two menus. The difference to the modules list is that we want the menu to be vertical in the small size and horizontal in all other sizes. Update the app/components/nav-bar.js:

import Component from '@glimmer/component';
import { inject as service } from '@ember/service';
import { action } from '@ember/object';
import { tracked } from '@glimmer/tracking';

export default class NavBarComponent extends Component {
    @service intl;
    @service mediaQuery;
    @tracked menuVisible = false;

    get menuDirection() {
        if(this.mediaQuery.breakpoint === 'sm') {
            return 'vertical'
        } else {
            return 'horizontal'
        }
    }

    @action
    setLocale(primaryLocale) {
        let locale = ['en-us'];
        if(primaryLocale) {
            locale.splice(0, 0, primaryLocale)
        }
        this.intl.setLocale(locale)
    };

    @action
    toggleMenu() {
        this.menuVisible = !this.menuVisible;
    }
}

The menuDirection works the same as in the modules/modules-navbar.js component class, just that the horizontal/vertical are result are inverted. The other change is the new menuVisible property (again defaulting to false - mobile first) and the toggleMenu() action to show and hide the menu.

Before it will work, you need to update the translations, adding the new application.menu key:

generic:
  current_semester: Current Semester
  last_semester: Last Semester
application:
  athena_study_portal: Athena - Study Portal
  my_modules: My Modules
  my_exams: My Exams
  register: Register
  logout: Logout
  menu: Menu
modules:
  enroll: Enroll in Module
  create: Create a new Module
generic:
  current_semester: Aktuelles Semester
  last_semester: Letztes Semester
application:
  athena_study_portal: Athena - Studienportal
  my_modules: Meine Module
  my_exams: Meine Prüfungen
  register: Registrieren
  logout: Ausloggen
  menu: Menü
modules:
  enroll: In Modul Einschreiben
  create: Neues Modul Erstellen

If you now try this out and change the window size, you will see that in the small size the title bar appears and clicking on the menu icon shows and hides the menu, while for all other sizes, the menu is always visible, but the title bar is hidden.