Improving User Experience with the Contacts Table | Full-Stack Google Contacts Clone with Adonis.js/Node.js and Quasar (Vue.js)

Improving User Experience with the Contacts Table | Full-Stack Google Contacts Clone with Adonis.js/Node.js and Quasar (Vue.js)

The Contacts table isn't there yet when compared with that of contacts.google.com. In this lesson, we will improve the user interface and user experience of our Contacts table by:

  1. Merging the checkbox column with the avatar column,
  2. Removing the separation borders along the rows,
  3. Adding some JavaScript to manipulate the toggling of the visibility of the avatar and checkbox for each row when the avatar is hovered,
  4. Adding animations to the avatars when their visibility is toggled,
  5. Make sure that the rows are selectable in mobile devices and devices with pointers,
  6. Show action buttons for each row when the row is hovered.
  7. Show a toolbar above the Contacts table when there is a selection

The video below demonstrates the changes which will be made.

Let's start by creating a new branch for our repo:

# Make sure you are within your project
git checkout -b 07-user-experience-tweaks-for-contacts-table

Make some built-in animations available from the Quasar framework

Let's start by introduce some built-in animations from the Quasar framework. Open ui/quasar.conf.js. Search for animations. Add "flipInY", "flipOutY", "flipInX", "flipOutX" to the animations array as shown below:

-  animations: [],
+  animations: ["flipInY", "flipOutY", "flipInX", "flipOutX"],

Refer to this line in this snapshot file.

Add CSS hover effect for in-row actions

Here, we will introduce some media queries and styles which will make it possible for in-row actions to work when any row is hovered. Open ui/src/css/app.scss. Add the following at the end of the file.

Note that this is a Sass-style CSS which will be post-processed to regular CSS. Learn more about Sass here.

@media (hover: hover) and (pointer: fine) {
  .q-table tbody > tr {
    .q-toolbar {
      display: none !important;
    }
    &:hover {
      .q-toolbar {
        display: inline-flex !important;
      }
    }
  }
}

What do we have here?

  1. We use media queries to check if the device supports hovering and if the device has a precise (fine) pointer like a real mouse/trackpad. Read more about these here.

    1. The hover feature can take two values: hover or none. none is for matching devices which do not support hovering such as smartphones, touchscreens, and digitizers. hover is for matching devices with mouse, touch pads, pens (such as those for Samsung Note, Microsoft Surface, Nintendo controller, etc.
    2. The pointer feature can take three values: none, coarse, or fine. Devices with keyboard-only controls can be matched with pointer: none. Touchscreen phones can be matched with pointer: coarse. While all devices with trackpads/mouse can be matched with pointer: fine.
  2. We select the toolbar (q-toolbar) on each row (tr) within the table body (.q-table tbody) and make it hidden by default: display: none !important; Then we select that same row when hovered and make the q-toolbar display as inline-flex.

Add required properties to the column definition file.

Open ui/src/data/table-definitions/contacts.ts and add required: false to the column definition object for profilePicture. Add required: true to the rest of the objects. Your file should look like this after.

Bringing everything home inside Index.vue

Open ui/src/pages/Index.vue (our Contacts table component). Refer to this snapshot. Copy-and-paste all the content from the snapshot into your Index.vue file.

Firstly, we want to add two new props to the q-table component:

  1. First one is the separator prop which will provide a string value of none to switch off the display of borders above and below the table rows.

  2. We also introduce the visible-columns prop which used to specify which columns will be initially rendered irrespective of the columns defined in the column-definition file. We assign an array visibleColumns to it. The computation of the visibleColumns array will be discussed in the setup section.

Next, we have to achieve is merge the profilePicture column with the selection column. We achieve that within the body (#body) slot from Line 59 by introducing two q-avatar component (ignore the transition components wrapping the two q-avatar components) into the same table cell (q-td) as the q-checkbox) component. Before they were on different cells. The first q-avatar at Line 65 holds the placeholder image we used earlier, while the second one at Line 79, introduces an icon (q-icon) with a check mark. This avatar has a solid primary (deep blue) colour. Both avatar components are wrapped with Vue's transition components. The first transition component is only rendered when the row is not selected while the second one is rendered when the row is selected. This gives the blue avatar with checkmark when the row is selected.

With respect to the transitions, we simply used Vue's global transition component to wrap the two q-avatar instances. You can learn more about Vue's transitions here. We assign the flipInX and flipOutX animation classes to the first transition and flipInY and flipOutY animation classes to the second transition. These classes were earlier imported into our app at through the ui/quasar.conf.js file.

Below the transition component is the checkbox at Line 86. A style prop is applied so that the checkbox is not visible by default. It will only be visible with JavaScript toggling as will be discussed soon.

Outside the cell (q-td) containing the q-avatars and q-checkbox, we've assigned two event listeners to the q-tr component at Lines 55 and 56. We are listening for the mouseover and mouseleave events. The former for when the mouse hovers over each row and the latter for when the mouse leaves each row. Both trigger the same function handleMouseEvents which will be discussed in the script section.

We also add a click event listener to each q-avatar component within our body slot. The event listener will call the handleAvatarClick function and pass in the props from the body slot as the only parameter. The handleAvatarClick function will be discussed in the setup section below.

We also introduce a fresh slot (the header slot). The header slotis used to customise the entire header of our table. This is necessary because we want to introduce an extra column at the end of the header. The column keeps a blank space for the in-row action toolbar. It is only rendered if the device ishoverable(Line 42). We add the following within theq-table` component:

          <q-tr>
            <q-td auto-width>
              <q-checkbox v-model="props.selected" />
            </q-td>
            <q-td
              v-for="column in props.cols"
              :key="column.name"
              class="text-center"
            >
              {{ column.label }}
            </q-td>
            <q-td v-if="isHoverable" auto-width>&nbsp;</q-td>
          </q-tr>
        </template>

Above the q-table component (at Line 4), we introduce a template which wraps a q-toolbar component. The toolbar is only rendered when a row is selected. The toolbar contains a non-function button and a span which shows the number of rows which are selected. This is similar to the toolbar shown in the real Google Contacts app. However, this one is displayed above the table and not within the table header.

The last thing for the template section is adding a table cell q-td which contains a q-toolbar for the in-row action buttons. The q-toolbar contains three q-btn components which will be made functionality in subsequent lessons. The q-toolbar has a class, hidden, applied so that it is invisible by default. The visibility of this toolbar per row is toggled by the CSS we applied in ui/src/css/app.scss (see these lines).

The setup section

Within the setup section of our component:

  1. We define a constant (at Line 126) which holds the result a filter and map operations on our columns from the column-definition file. Here, we filter for column which are required (required: true) and then map the result to obtain only the column names. We had earlier on assigned required properties to all objects in the column definition file. See

    const visibleColumns = columns
       .filter((column) => column.required)
       .map((column) => column.name);
    

    This gives us an array of column names:

    ["firstName", "surname", "email1", "phoneNumber1", "jobTitleAndCompany"]
    

    You can also see this by inspecting for HomePage component within Vue devtools.

    The visibleColumns array is returned from the setup function and assigned to the visible-columns prop. This makes the profilePicture column not to be rendered by default.

  2. At Line 134, we define a constant isHoverable as computed ref. We use the window.matchMedia method to check if the device supports hovering. It method takes a string argument which is our media query ("(hover: hover) and (pointer: fine)"). This same media was used in our ui/src/css/app.scss file. Remember? If there is match, the matches property in the return object from window.matchMedia will have the value true.

     const isHoverable = computed(
       () => window.matchMedia("(hover: hover) and (pointer: fine)")?.matches
     );
    
  3. At Line 138, we define a constant isTouchEnabled as computed ref which checks if the device has "coarse" pointer. "Coarse" pointers refer to touchscreens in most cases. We check for this using the media query ("(any-pointer: coarse)"). If there is match, the matches property in the return object from window.matchMedia will have the value true.

     const isTouchEnabled = computed(
       () => window.matchMedia("(any-pointer: coarse)")?.matches
     );
    
  4. At Line 162, we introduce the handleMouseEvents event handler:

     const handleMouseEvents = function (event: MouseEvent) {
       if (isHoverable.value) {
         const eventName = event.type;
         const target = event.target as HTMLElement;
         const avatar = target.querySelector(".q-avatar") as HTMLElement;
         const checkbox = target.querySelector(".q-checkbox") as HTMLElement;
         if (avatar && checkbox) {
           if (eventName === "mouseover") {
             avatar.style.display = "none";
             checkbox.style.display = "inline-flex";
           }
           if (eventName === "mouseleave") {
             avatar.style.display = "inline-flex";
             checkbox.style.display = "none";
           }
         }
       }
     };
    

    This function receives the event payload (default behaviour) as the only parameter. We check if the device isHoverable before proceeding. If true, we obtain the name of the event from event.type. This will give us either mouseover or mouseleave since we are listening for both of them in the template section. We use querySelectors to get the q-avatar and q-checkbox elements. From the TypeScript perspective, we append as HTMLElement to cast the returned type of event.target to an HTMLElement type so that TypeScript won't complain about further operations on the target constant. Same with the avatar and checkbox constants.

    If both avatar and checkbox exist, we make the avatar invisible and the checkbox visible on mouseover. We reverse this on mouseleave.

  5. On Line 183, we introduce the handleAvatarClick event handler. This handler received the props object which was passed to it from the event listener. If the device is touch-enabled, we programmatically toggle the selection state of that row.

     const handleAvatarClick = function (props: { selected: boolean }) {
       if (isTouchEnabled.value) {
         props.selected = !props.selected;
       }
     };
    
  6. We make sure that these new constants are returned from the setup fuction:

    return {
      selected,
      columns,
      rows,
      visibleColumns,
      nextPage,
      loading,
      pagination: {
        rowsPerPage: 0,
        rowsNumber: rows.value.length,
      },
      onScroll,
+      handleMouseEvents,
+      isHoverable,
+      isTouchEnabled,
+      handleAvatarClick,
    };

This completes this lesson. I hope that you've learned something from this lesson.

Save all your files, commit and merge with the master branch.

git add .
git commit -m "feat(ui): complete validation for new contact form"
git push origin 07-user-experience-tweaks-for-contacts-table
git checkout master
git merge master 07-user-experience-tweaks-for-contacts-table
git push origin master