AppStepper.vue 9.97 KB
<script setup>
import stepperCheck from '@images/svg/stepper-check.svg'

const props = defineProps({
  items: {
    type: Array,
    required: true,
  },
  currentStep: {
    type: Number,
    required: false,
    default: 0,
  },
  direction: {
    type: String,
    required: false,
    default: 'horizontal',
  },
  iconSize: {
    type: [
      String,
      Number,
    ],
    required: false,
    default: 52,
  },
  isActiveStepValid: {
    type: Boolean,
    required: false,
    default: undefined,
  },
  align: {
    type: String,
    required: false,
    default: 'default',
  },
})

const emit = defineEmits(['update:currentStep'])

const currentStep = ref(props.currentStep || 0)
const activeOrCompletedStepsClasses = computed(() => index => index < currentStep.value ? 'stepper-steps-completed' : index === currentStep.value ? 'stepper-steps-active' : '')
const isHorizontalAndNotLastStep = computed(() => index => props.direction === 'horizontal' && props.items.length - 1 !== index)

// check if validation is enabled
const isValidationEnabled = computed(() => {
  return props.isActiveStepValid !== undefined
})

watchEffect(() => {
  if (props.currentStep !== undefined && props.currentStep < props.items.length && props.currentStep >= 0)
    currentStep.value = props.currentStep
  emit('update:currentStep', currentStep.value)
})
</script>

<template>
  <VSlideGroup
    v-model="currentStep"
    class="app-stepper"
    show-arrows
    :direction="props.direction"
    :class="`app-stepper-${props.align} ${props.items[0].icon ? 'app-stepper-icons' : ''}`"
  >
    <VSlideGroupItem
      v-for="(item, index) in props.items"
      :key="item.title"
      :value="index"
    >
      <div
        class="cursor-pointer app-stepper-step mx-1"
        :class="[
          (!props.isActiveStepValid && (isValidationEnabled)) && 'stepper-steps-invalid',
          activeOrCompletedStepsClasses(index),
        ]"
        @click="!isValidationEnabled && emit('update:currentStep', index)"
      >
        <!-- SECTION stepper step with icon -->
        <template v-if="item.icon">
          <div class="stepper-icon-step text-high-emphasis d-flex align-center gap-2">
            <!-- 👉 icon and title -->
            <div
              class="d-flex align-center gap-3 step-wrapper"
              :class="[props.direction === 'horizontal' && 'flex-column']"
            >
              <div class="stepper-icon">
                <template v-if="typeof item.icon === 'object'">
                  <Component :is="item.icon" />
                </template>

                <VIcon
                  v-else
                  :icon="item.icon"
                  :size="item.size || props.iconSize"
                />
              </div>

              <div>
                <p class="stepper-title font-weight-medium text-base mb-1">
                  {{ item.title }}
                </p>
                <p
                  v-if="item.subtitle"
                  class="stepper-subtitle text-sm mb-0"
                >
                  {{ item.subtitle }}
                </p>
              </div>
            </div>

            <!-- 👉 append chevron -->
            <VIcon
              v-if="isHorizontalAndNotLastStep(index)"
              class="flip-in-rtl stepper-chevron-indicator mx-6"
              size="18"
              icon="ri-arrow-right-s-line"
            />
          </div>
        </template>
        <!-- !SECTION  -->

        <!-- SECTION stepper step without icon -->
        <template v-else>
          <div class="d-flex align-center gap-x-2">
            <div class="d-flex align-center gap-2">
              <div
                class="d-flex align-center justify-center"
                style="block-size: 24px; inline-size: 24px;"
              >
                <!-- 👉 custom circle icon -->
                <template v-if="index >= currentStep">
                  <div
                    v-if="(!isValidationEnabled || props.isActiveStepValid || index !== currentStep)"
                    class="stepper-step-indicator"
                  />

                  <VIcon
                    v-else
                    icon="ri-error-warning-line"
                    size="24"
                    color="error"
                  />
                </template>

                <!-- 👉 step completed icon -->

                <component
                  :is="stepperCheck"
                  v-else
                  class="stepper-step-icon"
                />
              </div>

              <!-- 👉 Step Number -->
              <h4 :class="`${!item.subtitle ? 'text-h6' : 'text-h4'} step-number`">
                {{ (index + 1).toString().padStart(2, '0') }}
              </h4>
            </div>

            <!-- 👉 title and subtitle -->
            <div
              class="app-stepper-title-wrapper"
              style="line-height: 0;"
            >
              <h6 class="text-base font-weight-medium step-title">
                {{ item.title }}
              </h6>

              <p
                v-if="item.subtitle"
                class="text-sm step-subtitle mb-0"
              >
                {{ item.subtitle }}
              </p>
            </div>

            <!-- 👉 stepper step line -->
            <div
              v-if="isHorizontalAndNotLastStep(index)"
              class="stepper-step-line"
            />
          </div>

          <div
            v-if="props.direction === 'vertical' && props.items.length - 1 !== index"
            class="stepper-step-line vertical"
          />
        </template>
        <!-- !SECTION  -->
      </div>
    </VSlideGroupItem>
  </VSlideGroup>
</template>

<style lang="scss">
@use "@core/scss/base/mixins.scss";

/* stylelint-disable no-descending-specificity */
.app-stepper {
  &.app-stepper-default:not(.app-stepper-icons) .app-stepper-step:not(:last-child) {
    inline-size: 100%;
  }

  // 👉 stepper step with icon and  default
  .v-slide-group__content {
    .stepper-step-indicator {
      border: 0.1875rem solid rgb(var(--v-theme-primary));
      border-radius: 50%;
      background-color: rgb(var(--v-theme-surface));
      block-size: 1.25rem;
      inline-size: 1.25rem;
      opacity: var(--v-activated-opacity);
    }

    .stepper-step-line {
      border-radius: 0.1875rem;
      background-color: rgb(var(--v-theme-primary));
      block-size: 0.1875rem;
      opacity: var(--v-activated-opacity);
    }

    .stepper-step-line:not(.vertical) {
      inline-size: 100%;
      min-inline-size: 3rem;
    }

    .stepper-step-line.vertical {
      border-radius: 1.25rem;
      block-size: 1.25rem;
      inline-size: 0.1875rem;
      margin-inline-start: 0.625rem;
    }

    .stepper-chevron-indicator {
      color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
    }

    .stepper-steps-completed,
    .stepper-steps-active {
      .stepper-icon-step,
      .stepper-step-icon {
        color: rgb(var(--v-theme-primary)) !important;
      }

      .stepper-step-indicator {
        border-width: 0.3125rem;
        opacity: 1;
      }
    }

    .stepper-steps-completed {
      .stepper-step-line {
        opacity: 1;
      }

      .stepper-chevron-indicator {
        color: rgb(var(--v-theme-primary));
      }
    }

    .stepper-steps-invalid.stepper-steps-active {
      .stepper-icon-step,
      .step-number,
      .step-title,
      .step-subtitle {
        color: rgb(var(--v-theme-error)) !important;
      }
    }

    .app-stepper-step:not(.stepper-steps-active,.stepper-steps-completed) {
      .step-number {
        color: rgba(var(--v-theme-on-surface), var(--v-disabled-opacity));
      }
    }
  }

  .app-stepper-title-wrapper {
    text-wrap: nowrap;
  }

  // 👉 stepper step with bg color
  &.stepper-icon-step-bg {
    .v-slide-group__content {
      row-gap: 1.5rem;
    }

    .stepper-icon-step {
      .step-wrapper {
        flex-direction: row !important;
      }

      .stepper-icon {
        display: flex;
        align-items: center;
        justify-content: center;
        border-radius: 0.3125rem;
        background-color: rgba(var(--v-theme-on-surface), var(--v-hover-opacity));
        block-size: 2.5rem;
        color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
        inline-size: 2.5rem;
        margin-inline-end: 0.3rem;
      }

      .stepper-title,
      .stepper-subtitle {
        line-height: normal;
      }

      .stepper-title {
        color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
        font-size: 0.9375rem;
        line-height: 1.375rem;
      }

      .stepper-subtitle {
        color: rgba(var(--v-theme-on-surface), var(--v-medium-emphasis-opacity));
        font-size: 0.8125rem;
        font-weight: 400;
        line-height: 1.25rem;
      }
    }

    .stepper-steps-active {
      .stepper-icon-step {
        .stepper-icon {
          background-color: rgb(var(--v-theme-primary));
          color: rgba(var(--v-theme-on-primary));

          @include mixins.elevation(2);
        }
      }
    }

    .stepper-steps-completed {
      .stepper-icon-step {
        .stepper-icon {
          background: rgba(var(--v-theme-primary), 0.16);
          color: rgba(var(--v-theme-primary));
        }
      }
    }
  }

  // 👉 stepper alignment
  &.app-stepper-default {
    .v-slide-group__content {
      justify-content: space-between;
    }
  }

  &.app-stepper-center {
    .v-slide-group__content {
      justify-content: center;
    }
  }

  &.app-stepper- {
    .v-slide-group__content {
      justify-content: start;
    }
  }

  &.app-stepper-end {
    .v-slide-group__content {
      justify-content: end;
    }
  }

  &.app-stepper-icons {
    .app-stepper-step:not(.stepper-steps-active,.stepper-steps-completed) {
      .stepper-title {
        color: rgba(var(--v-theme-on-surface), var(--v-high-emphasis-opacity));
      }
    }

    &:not(.stepper-icon-step-bg) {
      .step-wrapper {
        padding-block: 1.25rem;
        padding-inline: 1.875rem;
      }
    }

    &.v-slide-group--vertical {
      .step-wrapper {
        padding-inline: 0;
      }
    }
  }
}
</style>