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:
- Merging the
checkbox
column with theavatar
column, - Removing the separation borders along the rows,
- Adding some JavaScript to manipulate the toggling of the visibility of the avatar and checkbox for each row when the avatar is hovered,
- Adding animations to the avatars when their visibility is toggled,
- Make sure that the rows are selectable in mobile devices and devices with pointers,
- Show action buttons for each row when the row is hovered.
- 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?
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.
- The hover feature can take two values:
hover
ornone
.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. - The pointer feature can take three values:
none
,coarse
, orfine
. Devices with keyboard-only controls can be matched withpointer: none
. Touchscreen phones can be matched withpointer: coarse
. While all devices with trackpads/mouse can be matched withpointer: fine
.
- The hover feature can take two values:
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 theq-toolbar
display asinline-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:
First one is the
separator
prop which will provide a string value ofnone
to switch off the display of borders above and below the table rows.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 arrayvisibleColumns
to it. The computation of thevisibleColumns
array will be discussed in thesetup
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-avatar
s 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 is
hoverable(Line 42). We add the following within the
q-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> </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:
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 assignedrequired
properties to all objects in the column definition file. Seeconst 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 thesetup
function and assigned to thevisible-columns
prop. This makes theprofilePicture
column not to be rendered by default.At Line 134, we define a constant
isHoverable
ascomputed ref
. We use thewindow.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 ourui/src/css/app.scss
file. Remember? If there is match, thematches
property in the return object fromwindow.matchMedia
will have the valuetrue
.const isHoverable = computed( () => window.matchMedia("(hover: hover) and (pointer: fine)")?.matches );
At Line 138, we define a constant
isTouchEnabled
ascomputed 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, thematches
property in the return object fromwindow.matchMedia
will have the valuetrue
.const isTouchEnabled = computed( () => window.matchMedia("(any-pointer: coarse)")?.matches );
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 fromevent.type
. This will give us eithermouseover
ormouseleave
since we are listening for both of them in thetemplate
section. We usequerySelector
s to get theq-avatar
andq-checkbox
elements. From the TypeScript perspective, we appendas HTMLElement
to cast the returned type ofevent.target
to anHTMLElement
type so that TypeScript won't complain about further operations on thetarget
constant. Same with theavatar
andcheckbox
constants.If both
avatar
andcheckbox
exist, we make the avatar invisible and the checkbox visible onmouseover
. We reverse this onmouseleave
.On Line 183, we introduce the
handleAvatarClick
event handler. This handler received theprops
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; } };
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