Log[2] - Giary - Breaking down a design into components with Vue & Tailwind CSS

duy_anh_ngac

Duy Anh Ngac

Posted on June 18, 2021

Log[2] - Giary - Breaking down a design into components with Vue & Tailwind CSS

Disclaimer: I have omitted a lot of code for some components to keep it short. Where the code is not documented, a link to a repo is provided instead.

TL;DR:

With a rough design for the Giary app is completed, it is finally time to open a code editor and start the implementation.

Vue project setup

As I previously decided, I will be using a Vue3 as my front-end framework. A quick and easy way to scaffold a base project is to use Vue CLI, which will help a lot with generating a properly configured template.

Once the Vue CLI is installed globally on the machine, a project can be created with a command:

vue create giary
Enter fullscreen mode Exit fullscreen mode

A list of presets will be prompt for a selection. I went with Manually select features and below is the actual selection and the motivation behind it:

 ◉ Choose Vue version
 ◉ Babel
 ◉ TypeScript
 ◯ Progressive Web App (PWA) Support
 ◉ Router
 ◉ Vuex
 ◯ CSS Pre-processors
 ◉ Linter / Formatter
 ◯ Unit Testing
 ◯ E2E Testing
Enter fullscreen mode Exit fullscreen mode
  • Vue 3 has first-class citizen support for typescript
? Choose a version of Vue.js that you want to start the project with 
  2.x 
❯ 3.x 
Enter fullscreen mode Exit fullscreen mode
  • Typescript is a static type system that can help prevent many potential runtime errors as applications grow
? Use class-style component syntax? No
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? Yes
Enter fullscreen mode Exit fullscreen mode
  • A Router is a navigation between pages with a history mode
? Use history mode for a router? (Requires proper server setup for index fallback in production) (Y/n) Y
Enter fullscreen mode Exit fullscreen mode
  • Vuex is for state management and communication between the components
  • Linter / Formatter for consistent formatting and to avoid unwanted patterns
? Pick a linter / formatter config: 
  ESLint with error prevention only 
  ESLint + Airbnb config 
  ESLint + Standard config 
❯ ESLint + Prettier 
  TSLint (deprecated) 

? Pick additional lint features: (Press <space> to select, <a> to toggle all, <i> to invert selection)
 ◉ Lint on save
Enter fullscreen mode Exit fullscreen mode

In the future, if requirements for other features occurs (i.e. Progressive Web App (PWA) Support), they all can be easily added with a help of a rich plugin ecosystem.

Like with every project I start, I like to have a 100% clean project, which means removing all of the example code & files:

  • remove all the routes from src/router/index.ts
  • remove all the files from src/components
  • remove all the files from src/views

Installing tailwind CSS & Icon pack

Installing Tailwind CSS came with a small caveat. Following official documentation for installation and compiling a project will result in the following error:

Error: PostCSS plugin tailwindcss requires PostCSS 8.
Enter fullscreen mode Exit fullscreen mode

The error is self-explanatory and Vue 3 does support PostCSS 8 as well. The problem comes from the Vue CLI that doesn’t support PostCSS 8 just yet, so a workaround installation is required:

yarn add -D tailwindcss@npm:@tailwindcss/postcss7-compat postcss@^7 autoprefixer@^9
Enter fullscreen mode Exit fullscreen mode

The compatibility build is identical to the main build in every way, so you aren’t missing out on any features or anything like that.

/Note: A support for PostCSS8 will be available in version 5 which is currently in the beta stage./

After that, setting up Tailwind CSS is identical to the documentation.

For the icons, I use the Heroicons pack, which is from the creators of Tailwind CSS and they are greatly customizable with the same classes as Tailwind CSS.

yard add @heroicons/vue #npm install @heroicons/vue
Enter fullscreen mode Exit fullscreen mode

Now each icon can be imported individually as a Vue component:

<template>
  <div>
    <BeakerIcon class="h-5 w-5 text-blue-500"/>
    <p>...</p>
  </div>
</template>

<script>
import { BeakerIcon } from '@heroicons/vue/solid'

export default {
  components: { BeakerIcon }
}
</script>
Enter fullscreen mode Exit fullscreen mode

/Note: Heroicons currently only supports Vue 3./

Navigation to pages

All the pages will be located in the src/views directory. The very first page a user will see is “My Goals” which will be the home page as well. I usually create a file with simple text to make sure that the page is accessible.

[src/views/Home.vue]

<tempalte>
    <h1>My Goals page</h1>
</template>
Enter fullscreen mode Exit fullscreen mode

To make this page accessible, a vue-router must be mapped with our view which can be added as below:

[src/router/index.ts]

const routes: Array<RouteRecordRaw> = [
  {
    path: "/",
    name: "homeView",
    component: Home,
  },
];
Enter fullscreen mode Exit fullscreen mode

The next step is to tell a vue-router where to display with <RouterView />

[src/App.vue]

<template>
  <RouterView />
</template>
Enter fullscreen mode Exit fullscreen mode

Now the Home.vue page should be accessible via http://localhost:8080/.

Repeatable elements

A very big benefit about prototyping before actually coding a design is all the repeatable elements can be identified and can be extracted straight away.

A principle in extracting components to keep in mind. A component design must be from edge to edge, the spacing of the component, where the component is and how a component is displayed will be controlled by the parent’s components. With this principle, it will be much easier to update the design or swap the components.

[Button.vue]

<template>
    <button class="px-2 py-1 border border-gray-200">Click me</button>
</template>
Enter fullscreen mode Exit fullscreen mode

[Parent.vue]

<template>
    <div class="flex" >
        <Button class="mt-10" />
        <Button class="mr-10" />
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

Extracting the “Base Layout” component

All of the pages based on the design have one thing in common. That is a Header where the title of the page, navigation & other secondary information lives. And the main part where the dynamic content is displayed. Straight away this can be extracted to a separate component, which can be reused through the application.

[src/components/BaseLayout.vue]

<template>
  <div>
    <header class="h-56 bg-green-400 px-5">
      <div class="relative h-full container mx-auto max-w-screen-md">
        <div class="absolute top-0 w-full flex justify-between pt-3 text-sm">
          <p></p>
          <p>01.01.2021</p>
        </div>

        <div class="flex flex-col h-full items-center justify-center">
          <slot name="header"></slot>
        </div>
      </div>
    </header>

    <main class="mx-auto container max-w-screen-md pt-5 px-5 pb-10">
      <slot></slot>
    </main>
  </div>
</template>
Enter fullscreen mode Exit fullscreen mode

In this component, I am utilizing the named slots, where the actual content will be placed and that content will always stay at the correct place without messing up a whole page layout.

Extracting the “Goal List Item” component

While this element does not appear anywhere except “My Goals” page it is still a great idea to extract it in the event the amount of information displayed grows an can be changed in isolation.

[src/components/AppGoalListItem.vue]

<template>
  <li class="p-10 border border-gray-200 rounded-md text-left">
    <div class="flex items-center justify-between mb-5">
      <p class="text-gray-400 text-sm">31/12/2021</p>
      <div class="space-x-2 text-gray-400 -mt-5">
        <button>
          <PencilIcon class="h-5 w-5 hover:text-gray-700" />
        </button>
        <button>
          <TrashIcon class="h-5 w-5 hover:text-gray-700" />
        </button>
      </div>
    </div>

    <div class="flex items-center">
      <p class="text-lg font-light">
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro debitis
        quo nam hic a cupiditate ex illum id. Enim voluptates, est vero possimus
        sunt accusamus corporis recusandae pariatur delectus ipsa?
      </p>
    </div>
  </li>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { PencilIcon, TrashIcon } from "@heroicons/vue/outline";

export default defineComponent({
  components: { PencilIcon, TrashIcon },
});
</script>
Enter fullscreen mode Exit fullscreen mode

Extracting the “Goal Form” component

Right now both “Goal” & “Task” forms are almost the same having a simple text input. A small difference is a goal has a due date. Technically the same component can be used to display a due date input conditionally. However, I think, a much better approach would be to separate these two forms for few reasons:

  • The form can be styled differently if required
  • Add or delete additional fields (if required) to the form without affecting one another
  • Removing conditional logic to control an input visibility
  • Simplify the code

[src/components/AppGoalForm.vue]

<template>
  <div>
    <button
      v-if="!isVisibleGoalForm"
      class="py-10 border border-gray-200 rounded-md hover:bg-gray-50 w-full"
      @click="showGoalInput()"
    >
      + Add Goal
    </button>

    <form v-else class="space-y-2" @submit.prevent="submit()">
      <textarea
        ref="goalTextArea"
        class="px-5 py-2 w-full border border-gray-200 rounded-md resize-none"
        rows="3"
        placeholder="Write down your S.M.A.R.T goal here ..."
      ></textarea>

      <input
        class="px-5 py-2 w-full border border-gray-200 rounded-md resize-none"
        type="text"
        placeholder="Achieve my goal by dd/mm/yyyy ..."
      />

      <div class="flex justify-end space-x-3">
        <button
          type="button"
          class="px-4 py-2 rounded-md border border-gray-200 hover:bg-gray-100"
          @click="isVisibleGoalForm = false"
        >
          Cancel
        </button>

        <button
          type="submit"
          class="px-4 py-2 rounded-md bg-green-400 text-white hover:bg-green-500"
        >
          <CubeTransparentIcon
            v-if="isSubmitting"
            class="h-5 w-5 animate-spin"
          />
          <span v-else>Add Goal</span>
        </button>
      </div>
    </form>
  </div>
</template>

<script lang="ts">
import { defineComponent, nextTick, ref } from "vue";
import { CubeTransparentIcon } from "@heroicons/vue/outline";

export default defineComponent({
  components: {
    CubeTransparentIcon,
  },
  emits: ["submitted"],
  setup(_props, { emit }) {
    const goalTextArea = ref(null as unknown as HTMLTextAreaElement);
    const isVisibleGoalForm = ref(false);

    function showGoalInput() {
      isVisibleGoalForm.value = !isVisibleGoalForm.value;

      nextTick(() => {
        goalTextArea.value.focus();
      });
    }

    const isSubmitting = ref(false);

    function submit() {
      isSubmitting.value = true;

      setTimeout(() => {
        emit("submitted");
        isVisibleGoalForm.value = false;
        isSubmitting.value = false;
      }, 500);
    }

    return {
      goalTextArea,
      isVisibleGoalForm,
      showGoalInput,
      isSubmitting,
      submit,
    };
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

The form is hidden behind a big Add Goal button, and that’s why I added a very simple interaction functionality for better visualization with simple events.

As I am using vue composition-api, the way events are emitted are slightly different from options api. The difference is that you have to indicate what events will be emitted by a component. The emit action itself must be done via context provided to setup()

<script>
export default {
  // Vue 2.x
  methods: {
    click() {
      this.$emit("clicked");
    },
  },

  // Vue 3.x
  emits: ["clicked"],
  setup(_, { emit }) {
    function click() {
      emit("clicked");
    }

    return {
      click,
    };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Assembling “My Goals” page

Most of the components for the “Home” page have been extracted, it becomes very easy to assemble a whole page.

[src/views/Home.vue]

<template>
  <BaseLayout>
    <template #header>
      <h1 class="h-title">My Goals</h1>
    </template>

    <p class="text-sm italic text-gray-500">You can have up to 2 goals</p>

    <ul class="mt-5 space-y-8">
      <RouterLink v-slot="{ navigate }" to="/weekly-plan" custom>
        <AppGoalListItem
          class="hover:bg-gray-50 cursor-pointer"
          @click="navigate"
        />
        <AppGoalListItem
          v-if="isGoalVisible"
          class="hover:bg-gray-50 cursor-pointer"
          @click="navigate"
        />
      </RouterLink>
    </ul>

    <AppGoalForm
      v-if="!isGoalVisible"
      class="mt-8"
      @submitted="isGoalVisible = true"
    />
  </BaseLayout>
</template>

<script lang="ts">
import { defineComponent, ref } from "vue";
import AppGoalListItem from "@/components/AppGoalListItem.vue";
import AppGoalForm from "@/components/AppGoalForm.vue";

export default defineComponent({
  components: { AppGoalListItem, AppGoalForm },
  setup() {
    const isGoalVisible = ref(false);

    return {
      isGoalVisible,
    };
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

There is a very interesting component <RouterLink>, which wraps an AppTaskListItem component. By default a <RouterLink> component will always renders as <a> Html tag. I want to change this behaviour so a link will render as a root element of the AppTaskListItem component. In Vue Router 3.x version, it was just a matter of adding a tag prop, but it has been changed sin Vue Router 4.x version through a scoped slot.

<template>
  <!-- Vue Router 3.x -->
  <RouterLink to='/' tag='li'>Go to home</RouterLink>


  <!-- Vue Router 4.x -->
  <!-- pass the custom option to <router-link> to prevent it from wrapping its content inside of an <a> element. -->
  <RouterLink
      to="/about"
      custom
      v-slot="{ href, route, navigate, isActive, isExactActive }"
  >
      <NavLink :active="isActive" :href="href" @click="navigate">
        {{ route.fullPath }}
      </NavLink>
  </RouterLink>
</template>
Enter fullscreen mode Exit fullscreen mode

For more details on the <RouterLink>’s v-slot check out the official documentation

Extracting the “Task List Item” component

Another element to be extracted is “Task List Item” which is will be shared on “Weekly” & “Daily” pages. Based on the design a list item can have 4 states.

  • the active state is when a task can be edited or deleted
  • the underReview state is when a task can be completed, deleted or rescheduled
  • the reviewed state is when a task can be reverted to its original state
  • the past state is when a task only indicates the status

[src/components/AppTaskListItem.vue]

<template>
  <li class="p-10 border border-gray-200 rounded-md text-left">
    <div
      v-if="state === 'active' || state === 'underReview'"
      class="flex items-center justify-end mb-5"
    >
      <div class="space-x-2 text-gray-400 -mt-5">
        <template v-if="state === 'active'">
          <button @click="actionClicked()">
            <PencilIcon class="h-5 w-5 hover:text-gray-700" />
          </button>
          <button @click="actionClicked()">
            <TrashIcon class="h-5 w-5 hover:text-gray-700" />
          </button>
        </template>

        <template v-if="state === 'underReview'">
          <button @click="actionClicked()">
            <CheckIcon class="h-5 w-5 hover:text-gray-700" />
          </button>
          <button @click="actionClicked()">
            <LogoutIcon class="h-5 w-5 hover:text-gray-700" />
          </button>
          <button @click="actionClicked()">
            <TrashIcon class="h-5 w-5 hover:text-gray-700" />
          </button>
        </template>
      </div>
    </div>

    <div class="flex items-center">
      <LogoutIcon v-if="state === 'past'" class="h-14 w-14 mr-10" />

      <template v-if="state === 'reviewed'">
        <div class="flex space-x-2 mr-9">
          <LogoutIcon class="h-5 w-5" />
          <button @click="actionClicked()">
            <RefreshIcon class="h-5 w-5 text-gray-400 hover:text-gray-700" />
          </button>
        </div>
      </template>

      <p class="text-lg font-light">
        Lorem ipsum dolor sit amet consectetur adipisicing elit. Porro debitis
        quo nam hic a cupiditate ex illum id. Enim voluptates, est vero possimus
        sunt accusamus corporis recusandae pariatur delectus ipsa?
      </p>
    </div>
  </li>
</template>

<script lang="ts">
import { defineComponent } from "vue";
import { PencilIcon, TrashIcon, CheckIcon, LogoutIcon, RefreshIcon } from "@heroicons/vue/outline";

export default defineComponent({
  components: { PencilIcon, TrashIcon, CheckIcon, LogoutIcon, RefreshIcon },
  props: {
    state: {
      type: String,
      default: "active",
      validator: (value: string): boolean => {
        return ["active", "underReview", "reviewed", "past"].includes(value);
      },
    },
  },
  emits: ["actionClicked"],
  setup(_props, { emit }) {
    function actionClicked() {
      emit("actionClicked");
    }

    return {
      actionClicked,
    };
  },
});
</script>
Enter fullscreen mode Exit fullscreen mode

The state of the component is controller by the props passed from a parent component. In composition api the way props used is also different. The setup() option is executed before the component is created, once the props are resolved, and serves as the entry point for composition APIs, which means that this inside setup() won’t refer to the component instance. That’s why props are passed to setup() as a first argument

<script>
export default {
  // Vue 2.x
    props: {
        title: String,
    }
  methods: {
    click() {
      console.log(this.title);
    },
  },

  // Vue 3.x
    props: {
        title: String,
    },
  setup(props) {
    function click() {
      console.log(props.title);
    }

    return {
      click,
    };
  },
};
</script>
Enter fullscreen mode Exit fullscreen mode

Now the component can be reused everywhere and a design will change based on the required state. For the past state, I have hardcoded an icon, but it will be updated to have a dynamic icon based on the actual status of the task. A lot of dummy functions just to simulate the actions.

<template>
    <div>
         <AppTaskListItem />
         <AppTaskListItem state="underReview" />
         <AppTaskListItem state="reviewed" />
         <AppTaskListItem state="past" />
    </div>
</template>
Enter fullscreen mode Exit fullscreen mode

It is not necessary to indicate an active state as it is a default option for the component.

Extracting the “Task Form”

It is the same as the Goal form with a small difference of not having an input for the due date. I won’t document the code here.

Full implementation of the component can be found here.

Extracting the “Date Navigation”

Both “Weekly” and “Daily” pages have a navigation based on the date. The difference is the navigation per week & day respectively. In the future, it should be dynamically switched, but for simplicity sake, it will be static. I have also included a few dummy functions to simulate the navigation by emitting the events. The component provides the information that the action happened, but it is up to a parent component to decide what do to with that information.

Full implementation of the component can be found here.

Extracting the “Page Navigation”

Another common element between the “Weekly” and “Daily” pages buttons below the header to navigate between the pages. This is also can be extracted to a separate component. The text and a link of the navigation trigger are controlled by a parent via props.

Full implementation of the component can be found here.

Assembling the “Weekly Planning” page

Now every component for the “Weekly” pages is ready, it is just a matter of simply assembling a page. I have added a simple function which handles emitted event from the AppDateNav.vue component and simulating a past “Weekly Planning” state.

Full implementation of the component can be found here.

Extracting the “Additional Task List Item” component

On the “Daily” page there is also a section for “additional tasks”, which are slightly different from the AppTaskListItem.vue design. Similar to AppGoalForm.vue & AppTaskForm.vue, I want to keep both components separated. Since both task components are almost the same, I won’t document the code here

Full implementation of the component can be found here.

Assembling the “Daily Planning” page

With that the AppTaskSecondaryListItem component ready for the “Daily Planning” page. All that is left is to assemble it with some dummy functions for interactivity. Rinse and repeat at this stage, placing the components at the correct location in the layout.

Full implementation of the component can be found here.

Assembling the “Daily & Weekly Review” page

All the components for the page are readily available, the only different thing is to indicate the correct state of the tasks, making sure to add a link to a src/router/index.ts file, so a page can be available.

Full implementation for Daily Review the component can be found here.

Full implementation for Weekly Review the component can be found here.

Extracting common styles

I found myself repeating the same classes over and over again for the head title and decided to extract it to a separate class.

[src/index.css]

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer components {
  .h-title {
    @apply text-center text-white font-black capitalize text-5xl;
  }
}
Enter fullscreen mode Exit fullscreen mode

Conclusion

Somehow this turned out to be a very long post, but I covered all the basic ideas, the progress of extracting & separating different elements into reusable components.

There is one more extraction to a separate component that can be done. That is a whole header section of the “Weekly” & “Daily” pages, but I decided not to do so until the implementation of business logic. In general, extracting repeatable elements to their elements provides multiple benefits:

  • Reduce the amount of code per single file
  • Reduce the responsibility of the component
  • Provide reusability of the code
  • Reduce the risk of breaking things in the event of a code update
  • Styles are separate and can be easily updated without breaking the whole layout

The components as they are now are very simple and will change once the business logic is implemented. Of course, not everything must be its component, as ‘too much separation can quickly transform to a useless ‘wrappers’.

For now, it is a great starting point in visualizing and polishing a general user flow without worrying too much about user-provided content.

Like this? Buy me a coffee

💖 💪 🙅 🚩
duy_anh_ngac
Duy Anh Ngac

Posted on June 18, 2021

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related