# Introduction

Vue meeting selector is a lightweight, meeting selector fast and fully custom.

Vue meeting selector is inspired by the doctolib component. It allows you to easily select an appointment from a list sort by days. It provides different tools such as a loading, a pagination, ...

# Instalation

Dependencies
  • required: Vuejs >= 2.6.x

$ npm install vue-meeting-selector --save

In your component

import VueMeetingSelector from 'vue-meeting-selector';

# What's new

  • 3.1.0

    Add spacing params. When clicking next, scroll a custom number of slots

    3.0.0

    Upgrade to vue3, build with vite (need to update readme)

    1.1.0

    Set possible to have multi meetings selected

  • 1.0.0

    First release !!

# Props

ParamsType
v-modelObject (MeetingSlot) | MeetingSlot[] (if multi)
dateDate | string
meetingsDaysArray (MeetingsDays[])
calendarOptionsObject (CalendarOptions)
classNamesObject (ClassNames)
loadingboolean
multiboolean
MeetingSlot
interface MeetingSlot {
  date: Date | string;
  [key: string]: any;
}
MeetingsDay
interface MeetingsDay {
  date: Date | string;
  slots: MeetingSlot[];
  [key: string]: any;
}
ClassNames
interface ClassNames {
  tabClass?: string,
  tabPaginationleft?: string,
  tabPaginationPreviousButton?: string,
  tabPaginationRight?: string,
  tabPaginationNextButton?: string,
  tabPaginationUpButton?: string,
  tabPaginationDownButton?: string,
  tabDays?: string,
  tabDayDisplay?: string,
  tabMeetings?: string,
  tabMeeting?: string,
  tabMeetingButton?: string,
  tabMeetingEmpty?: string,
  tabEmpty?: string,
}
CalendarOptions

defaults value are available in src/defaults/calendarOptions.ts

interface CalendarOptions {
  daysLabel: string[]; // Labels for days in title, start by sunday
  monthsLabel: string[]; // labels for months in title, start by january
  limit: number, // max nb meetings to display on a same column
  spacing: number, // When clicking next, how many cells do you want to scroll
  loadingLabel: string; // label to display when loading
  disabledDate: Function; // function to disable left button (date is passed as params)
}

# Events

nameparams
meeting-slot-selectedObject (MeetingSlot) | MeetingSlot[] (if multi)
meeting-slot-unselected-
changeObject (MeetingSlot) | MeetingSlot[] (if multi)
next-date-
previous-date-

# Slots and ScopedSlots

Header

To change head of every column, a `meetings` (MeetingsDay) is passed as slot-scope.

<template #header="{ meetings }">
  <div>{{ meetings.date }}</div>
</template>
Next and previous

To change the previous/next button. (date selector)

<template #button-previous>
  <button
    @click="previous">
    previous
  </button>
  </template>
  <template #button-next>
  <button
    @click="next">
    next
  </button>
</template>
top and down

To change up/down button to change hours of meetings, you will have to trigger methods with refs disabled is passed.
It returns true when you are at top of meetings for button-up.
It returns true when you are at bottom of meetings for button-down.

<template #button-previous>
  <button
    @click="previous">
    previous
  </button>
  </template>
  <template #button-next>
  <button
    @click="next">
    next
  </button>
</template>
meeting

To change the display of a meeting. (you will have to manually change the v-model) if the meeting don't have date, it's because the is no meeting. (you will have to handle a different display)

<template #meeting="{ meeting }">
  <div>{{ meeting.date }}</div>
</template>
loading

To change the display of loading

<template #loading>
  Loading ...
</template>

# Simple example

Full code is available on src/components/Examples/SimpleExample.vue

Loading ...

meeting Selected: No Meeting selected

Code

<template>
  <div class="simple-example">
    <vue-meeting-selector
      class="simple-example__meeting-selector"
      v-model="meeting"
      :date="date"
      :loading="loading"
      :class-names="classNames"
      :meetings-days="meetingsDays"
      @next-date="nextDate"
      @previous-date="previousDate"
    />
    <p>meeting Selected: {{ meeting ? meeting : 'No Meeting selected' }}</p>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  onMounted,
  ref,
  computed,
} from 'vue';

import VueMeetingSelector from 'vue-meeting-selector';
import 'vue-meeting-selector/dist/style.css';
// Function used to generate slots, use your own function
import slotsGenerator from 'vue-meeting-selector/src/helpers/slotsGenerator';

import type MeetingsDay from 'vue-meeting-selector/src/interfaces/MeetingsDay.interface';
import type MeetingSlot from 'vue-meeting-selector/src/interfaces/MeetingSlot.interface';
import type Time from 'vue-meeting-selector/src/interfaces/Time.interface';

export default defineComponent({
  name: 'SimpleExample',
  components: {
    VueMeetingSelector,
  },
  setup() {
    const date = ref(new Date());
    const meetingsDays = ref<MeetingsDay[]>([]);
    const meeting = ref<MeetingSlot | null>(null);
    const loading = ref(true);

    const nbDaysToDisplay = computed(() => 5);

    // because of line-height, font-type you might need to change top value
    const classNames = computed(() => ({
      tabLoading: 'loading-div',
    }));

    // juste set async the gettings of meeting to display loading
    const slotsGeneratorAsync = (
      d: Date, // date
      n: number, // nbDaysToDisplay
      start: Time,
      end: Time,
      timesBetween: number,
    ):Promise<MeetingsDay[]> => new Promise((resolve) => {
      setTimeout(() => {
        resolve(slotsGenerator(d, n, start, end, timesBetween));
      }, 1000);
    });

    const nextDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7));
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    const previousDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      dateCopy.setDate(dateCopy.getDate() - 7);
      const formatingDate = (dateToFormat: Date): String => {
        const d = new Date(dateToFormat);
        const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
        const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1;
        const year = d.getFullYear();
        return `${year}-${month}-${day}`;
      };
      const newDate = formatingDate(new Date()) >= formatingDate(dateCopy)
        ? new Date()
        : new Date(dateCopy);
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    onMounted(async () => {
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      meetingsDays.value = await slotsGeneratorAsync(
        date.value,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    });

    return {
      date,
      meetingsDays,
      loading,
      nbDaysToDisplay,
      classNames,
      nextDate,
      previousDate,
    };
  },
});
</script>

<style scoped lang="scss">
.simple-example {
  &__meeting-selector {
    max-width: 542px;
  }
}
// since our scss is scoped we need to use ::v-deep
:deep(.loading-div) {
  top: 58px!important;
  meeting,
}
</style>

# Simple multi example

Full code is available on src/components/Examples/SimpleMultiExample.vue

Loading ...

meeting Selected: No Meeting selected

Code

<template>
  <div class="simple-multi-example">
    <vue-meeting-selector
      class="simple-multi-example__meeting-selector"
      v-model="meeting"
      :date="date"
      :loading="loading"
      :class-names="classNames"
      :meetings-days="meetingsDays"
      :multi="true"
      @next-date="nextDate"
      @previous-date="previousDate"
    />
    <p>meeting Selected: {{ meeting.length ? meeting : 'No Meeting selected' }}</p>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  onMounted,
  ref,
  computed,
} from 'vue';

import VueMeetingSelector from 'vue-meeting-selector';
import 'vue-meeting-selector/dist/style.css';
// Function used to generate slots, use your own function
import slotsGenerator from 'vue-meeting-selector/src/helpers/slotsGenerator';

import type MeetingsDay from 'vue-meeting-selector/src/interfaces/MeetingsDay.interface';
import type MeetingSlot from 'vue-meeting-selector/src/interfaces/MeetingSlot.interface';
import type Time from 'vue-meeting-selector/src/interfaces/Time.interface';

export default defineComponent({
  name: 'SimpleMultiExample',
  components: {
    VueMeetingSelector,
  },
  setup() {
    const date = ref(new Date());
    const meetingsDays = ref<MeetingsDay[]>([]);
    const meeting = ref<MeetingSlot[]>([]);
    const loading = ref(true);

    const nbDaysToDisplay = computed(() => 5);

    // because of line-height, font-type you might need to change top value
    const classNames = computed(() => ({
      tabLoading: 'loading-div',
    }));

    // juste set async the gettings of meeting to display loading
    const slotsGeneratorAsync = (
      d: Date, // date
      n: number, // nbDaysToDisplay
      start: Time,
      end: Time,
      timesBetween: number,
    ):Promise<MeetingsDay[]> => new Promise((resolve) => {
      setTimeout(() => {
        resolve(slotsGenerator(d, n, start, end, timesBetween));
      }, 1000);
    });

    const nextDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7));
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    const previousDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      dateCopy.setDate(dateCopy.getDate() - 7);
      const formatingDate = (dateToFormat: Date): String => {
        const d = new Date(dateToFormat);
        const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
        const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1;
        const year = d.getFullYear();
        return `${year}-${month}-${day}`;
      };
      const newDate = formatingDate(new Date()) >= formatingDate(dateCopy)
        ? new Date()
        : new Date(dateCopy);
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    onMounted(async () => {
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      meetingsDays.value = await slotsGeneratorAsync(
        date.value,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    });

    return {
      date,
      meetingsDays,
      meeting,
      loading,
      nbDaysToDisplay,
      classNames,
      nextDate,
      previousDate,
    };
  },
});
</script>

<style scoped lang="scss">
.simple-multi-example {
  &__meeting-selector {
    max-width: 542px;
  }
}
// since our scss is scoped we need to use ::v-deep
:deep(.loading-div) {
  top: 58px!important;
}
</style>

# Slots and scopedSlots example

full code is available on src/components/Examples/SlotsExample.vue

Loading ...

meeting Selected: No Meeting selected

Code

<template>
  <div class="slots-example">
    <vue-meeting-selector
      ref="meetingSelector"
      class="slots-example__meeting-selector"
      v-model="meeting"
      :date="date"
      :loading="loading"
      :class-names="classNames"
      :meetings-days="meetingsDays"
      @next-date="nextDate"
      @previous-date="previousDate">
      <template
        #header="{ meetings }">
        <div class="title">{{ formatingDateTitle(meetings.date) }}</div>
      </template>
      <template
        #meeting="{ meeting }">
        <div
          v-if="meeting.date"
          class="meeting"
          :class="meetingSelectedClass(meeting)"
          @click="selectMeeting(meeting)">
          {{ formatingTime(meeting.date) }}
        </div>
        <div v-else class="meeting--empty">
          &mdash;
        </div>
      </template>
      <template
        #loading>
        <div>Loading ...</div>
      </template>
      <template
        #button-previous>
        <button
          type="button"
          class="button-pagination"
          :disabled="isPreviousDisabled || loading"
          @click="previousDate">
          <font-awesome-icon :icon="['fas', 'chevron-left']" />
        </button>
      </template>
      <template
        #button-next>
        <button
          type="button"
          @click="nextDate"
          :disabled="loading"
          class="button-pagination">
          <font-awesome-icon :icon="['fas', 'chevron-right']" />
        </button>
      </template>
      <template
        #button-up="{ isDisabled }">
        <button
          type="button"
          @click="previousMeetings"
          class="button-pagination"
          :disabled="isDisabled">
          <font-awesome-icon :icon="['fas', 'chevron-up']" />
        </button>
      </template>
      <template
        #button-down="{ isDisabled }">
        <button
          type="button"
          @click="nextMeetings"
          class="button-pagination"
          :disabled="isDisabled">
          <font-awesome-icon :icon="['fas', 'chevron-down']" />
        </button>
      </template>
    </vue-meeting-selector>
    <p>meeting Selected: {{ meeting ? meeting : 'No Meeting selected' }}</p>
  </div>
</template>

<script lang="ts">
import {
  defineComponent,
  onMounted,
  ref,
  computed,
} from 'vue';

import VueMeetingSelector from 'vue-meeting-selector';
import 'vue-meeting-selector/dist/style.css';
// Function used to generate slots, use your own function
import slotsGenerator from 'vue-meeting-selector/src/helpers/slotsGenerator';

import type MeetingsDay from 'vue-meeting-selector/src/interfaces/MeetingsDay.interface';
import type MeetingSlot from 'vue-meeting-selector/src/interfaces/MeetingSlot.interface';
import type Time from 'vue-meeting-selector/src/interfaces/Time.interface';

export default defineComponent({
  name: 'SlotsExample',
  components: {
    VueMeetingSelector,
  },
  setup() {
    const date = ref(new Date());
    const meetingsDays = ref<MeetingsDay[]>([]);
    const meeting = ref<MeetingSlot | null>(null);
    const meetingSelector = ref<InstanceType<typeof VueMeetingSelector> | null>(null);
    const loading = ref(true);

    const nbDaysToDisplay = computed(() => 5);

    // because of line-height, font-type you might need to change top value
    const classNames = computed(() => ({
      tabLoading: 'loading-div',
    }));

    // juste set async the gettings of meeting to display loading
    const slotsGeneratorAsync = (
      d: Date, // date
      n: number, // nbDaysToDisplay
      start: Time,
      end: Time,
      timesBetween: number,
    ):Promise<MeetingsDay[]> => new Promise((resolve) => {
      setTimeout(() => {
        resolve(slotsGenerator(d, n, start, end, timesBetween));
      }, 1000);
    });

    const nextDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      const newDate = new Date(dateCopy.setDate(dateCopy.getDate() + 7));
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    const previousDate = async () => {
      loading.value = true;
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      const dateCopy = new Date(date.value);
      dateCopy.setDate(dateCopy.getDate() - 7);
      const formatingDate = (dateToFormat: Date): String => {
        const d = new Date(dateToFormat);
        const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
        const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1;
        const year = d.getFullYear();
        return `${year}-${month}-${day}`;
      };
      const newDate = formatingDate(new Date()) >= formatingDate(dateCopy)
        ? new Date()
        : new Date(dateCopy);
      date.value = newDate;
      meetingsDays.value = await slotsGeneratorAsync(
        newDate,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    };

    onMounted(async () => {
      const start: Time = {
        hours: 8,
        minutes: 0,
      };
      const end: Time = {
        hours: 16,
        minutes: 0,
      };
      meetingsDays.value = await slotsGeneratorAsync(
        date.value,
        nbDaysToDisplay.value,
        start,
        end,
        30,
      );
      loading.value = false;
    });

    const formatingDate = (dateToFormat: Date | string) => {
      const d = new Date(dateToFormat);
      const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
      const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1;
      const year = d.getFullYear();
      return `${year}-${month}-${day}`;
    };

    const formatingDateTitle = (dateToFormat: Date | string) => {
      const d = new Date(dateToFormat);
      const day = d.getDate() < 10 ? `0${d.getDate()}` : d.getDate();
      const month = d.getMonth() + 1 < 10 ? `0${d.getMonth() + 1}` : d.getMonth() + 1;
      return `${month}-${day}`;
    };

    const formatingTime = (dateToFormat: Date | string) => {
      const d = new Date(dateToFormat);
      const hours = d.getHours() < 10 ? `0${d.getHours()}` : d.getHours();
      const minutes = d.getMinutes() < 10 ? `0${d.getMinutes()}` : d.getMinutes();
      return `${hours}:${minutes}`;
    };

    const isPreviousDisabled = computed(() => {
      const d = new Date(date.value);
      d.setDate(d.getDate() - 1);
      return formatingDate(d) < formatingDate(new Date());
    });

    const meetingSelectedClass = (m: MeetingSlot) => {
      if (!meeting.value) {
        return '';
      }
      const selectedDate = new Date(m.date);
      const d = new Date(meeting.value.date);

      if (selectedDate.getTime() === d.getTime()) {
        return 'meeting--selected';
      }
      return '';
    };

    const selectMeeting = (m: MeetingSlot) => {
      if (meeting.value) {
        const selectedDate = new Date(m.date);
        const d = new Date(meeting.value.date);
        if (selectedDate.getTime() !== d.getTime()) {
          meeting.value = m;
        } else {
          meeting.value = null;
        }
      } else {
        meeting.value = m;
      }
    };

    const nextMeetings = () => {
      meetingSelector.value?.nextMeetings();
    };

    const previousMeetings = () => {
      meetingSelector.value?.previousMeetings();
    };

    return {
      date,
      meetingsDays,
      meeting,
      meetingSelector,
      loading,
      nbDaysToDisplay,
      classNames,
      nextDate,
      previousDate,
      formatingDateTitle,
      formatingTime,
      isPreviousDisabled,
      meetingSelectedClass,
      selectMeeting,
      nextMeetings,
      previousMeetings,
    };
  },
});
</script>

<style scoped lang="scss">
.slots-example {
  &__meeting-selector {
    max-width: 542px;
  }
}
.title {
  margin: 0 5px;
}
.meeting {
  display: inline-block;
  padding: 5px;
  margin: 5px 0;
  background-color: #845EC2;
  border-radius: 4px;
  color: white;
  cursor: pointer;
  &--selected {
    background-color: #B39CD0;
  }
  &--empty {
    display: inline-block;
    padding: 5px;
    margin: 5px 0;
    cursor: not-allowed;
  }
}
.button-pagination {
  border: none;
  padding: 0;
  width: 30px;
}
// since our scss is scoped we need to use ::v-deep
:deep(.loading-div) {
  top: 32px!important;
}
</style>