pss, эта статья похожа на другую мою статью о модальных окнах, посмотрите и ее! https://medium.com/itnext/how-to-build-a-reusable-modal-component-in-vuejs-f1799ab9b3e

Введение

Как вы все, наверное, знаете, пользовательский опыт очень важен при создании вашего веб-сайта. Когда пользователь взаимодействует с вашими кнопками и формами или когда что-то идет не так, важно как-то уведомить его о результате своих действий, чтобы операция казалась завершенной и отзывчивой.

Одним из решений для такого рода потребностей может быть использование тостов — простых всплывающих уведомлений в какой-то части экрана, которые мягко сообщают вам, все ли прошло хорошо или произошло что-то ужасное, и вам нужно перезагрузить страницу как можно быстрее.

И что касается многих вещей, в экосистеме Vue есть классная библиотека под названием vue-toastification, которая довольно хорошо справляется с этой функциональностью — у них есть миллион опций, их всплывающие уведомления изначально выглядят великолепно, сама библиотека легкая и все такое.

Но что, если у нас есть требование не зависеть от какого-то внешнего ресурса для нашего проекта? Или, как это случилось со мной, мы делаем какой-то веб-сайт Nuxt, на котором просто нет встроенного модуля для этой библиотеки, и каждая наша попытка выдает ошибку за ошибкой? В этот момент лучше просто успокоиться и разобраться с этим довольно простым вопросом самостоятельно с помощью подручных средств.

Рецепт

Чтобы построить наш компонент Toast, нам потребуются две части:

  1. Логика с отслеживанием состояния, в которой будет храниться вся информация о тостах.
  2. Интерфейс, который бы потреблял наше состояние и показывал пользователю, какие тосты в данный момент должны быть на экране.

Поскольку интерфейс не будет работать без логики, давайте сначала напишем ее!

Пиния магазин

Поскольку нам нужно было бы использовать нашу функциональность всплывающих окон буквально везде в нашем приложении, с целью хранения состояния мы будем использовать хранилище, концепция которого вам, вероятно, уже хорошо знакома. Для Vue 2 вы должны использовать Vuex, но поскольку я пишу эту статью в проекте Vue 3, мы будем использовать Pinia. Разница между ними в том, что VueX — это старый менеджер состояний со своими недостатками и низкой поддержкой Typescript, тогда как Pinia новее, признана и поддерживается сообществом в качестве официального менеджера состояний для Vue.

Установка Пинии

Для установки Pinia нам потребуется:

  1. Установите его как пакет (выберите менеджер пакетов):
//npm
npm install pinia
//yarn
yarn add pinia
//pnpm
pnpm add pinia

2. Создайте экземпляр pinia в файле main.ts:

import { createPinia } from "pinia";

const pinia = createPinia();

3. Используйте его в нашем приложении:

const app = createApp(App);

app.use(pinia);

Большой! Теперь мы можем определить наши магазины. Давайте создадим папку с именем stores, так как каждое хранилище Pinia представляет собой отдельный файл (в отличие от модульной системы Vuex), и создадим там файл useToasterStore , where we would define a store like this:

import { defineStore } from "pinia";

export const useToasterStore = defineStore("toaster-store", {
  state: () => ({}),
  actions: {},
});

Первый аргумент, который мы передаем в функцию, — это идентификатор хранилища, чтобы идентифицировать его среди всех других хранилищ, а второй — это объект, который содержит наше состояние (функция со стрелкой, которая — обратите внимание — возвращает объект в фигурных скобках) и действия, похожие на класс ' Методы работы с нашим государством.

Логика обработки

Поскольку одновременно может быть много уведомлений, мы будем использовать массив для хранения нашего состояния. Я продолжу и определю интерфейсы TypeScript заранее, но если вы кодируете на чистом Js, просто отбросьте их.

// Status will define toast color and icon
export type TToastStatus = "success" | "warning" | "error";

interface IToast {
// Text of toast
  text: string;
  status: TToastStatus;
// Id to differentiate toasts
  id: number;
}

export default defineStore("toaster-store", {
  state: (): { toasts: IToast[] } => ({
    toasts: [],
  }),
  actions: {}
});

Большой! Теперь мы определим несколько методов для изменения нашего состояния по мере необходимости. Поскольку количество статусов может измениться в будущем, давайте сначала определим метод, который будет чем-то вроде контроллера, ядра нашего магазина, чтобы позже повторно использовать его в будущем.

Во-первых, нам нужно определить способ передачи информации о тосте в функцию. Создаем для него тип:

// timeout is conditional because we will define default one
type ToastPayload = { timeout?: number; text: string };

Затем нам понадобится способ создания объектов всплывающих уведомлений — функция в верхней части файла (не в магазине).

const createToast = (text: string, status: TToastStatus): IToast => ({
  text,
  status,
  id: Math.random() * 1000,
});

Я определяю Id для дифференциации тостов в будущем и делаю это с помощью функции Math.random() * 1000, которая сгенерирует мне случайное число в диапазоне от тысячи. Вы можете использовать что-то вроде библиотеки uuid, но я думаю, что для нашего приложения это не нужно.

Наконец, прежде чем окончательно определить наш метод, мы создадим постоянный тайм-аут по умолчанию, чтобы нам не нужно было передавать продолжительность каждый раз, когда мы создаем общее всплывающее уведомление:

const defaultTimeout: number = 2000;

А вот и наша функция!

actions: {
    updateState(payload: ToastPayload, status: TToastStatus) {
// Get text and timeout from payload
      const { text, timeout } = payload;
// We create the toast with function above
      const toast = createToast(text, status);

// We push toasts to the state
      this.toasts.push(toast);

// We create a delay to delete toast after its provided timeout is over
      setTimeout(() => {
        this.toasts = this.toasts.filter((t) => t.id !== toast.id);
      }, timeout ?? defaultTimeout);
    }
}

Что он делает:

  1. Деструктурирует значения объекта полезной нагрузки, который мы предоставляем
  2. Создает общий объект всплывающего уведомления из этих значений
  3. Добавляет его в массив состояний нашего магазина
  4. Устанавливает тайм-аут для удаления нашего всплывающего сообщения из массива состояний по истечении заданного времени или если оно не было предоставлено (с использованием нулевого оператора объединения) — наш тайм-аут по умолчанию.

Сделав это, мы теперь можем создать три различных метода, которые мы сможем легко использовать в нашем приложении. Они будут служить своего рода абстракцией над нашим методом, который напрямую мутирует состояние, чтобы оно не мутировало каким-то непредсказуемым образом в будущем.

success(payload: ToastPayload) {
      this.updateState(payload, "success");
    },

warning(payload: ToastPayload) {
      this.updateState(payload, "warning");
    },

error(payload: ToastPayload) {
      this.updateState(payload, "error");
    },

В каждом из них мы используем наш updateState,, предоставляя разные типы тостов для каждого метода.

В итоге наш файл useToasterStore выглядит так:

import { defineStore } from "pinia";

export type TToastStatus = "success" | "warning" | "error";

interface IToast {
  text: string;
  status: TToastStatus;
  id: number;
}
type ToastPayload = { timeout?: number; text: string };

const defaultTimeout = 2000;

const createToast = (text: string, status: TToastStatus): IToast => ({
  text,
  status,
  id: Math.random() * 1000,
});

export default defineStore("toaster-store", {
  state: (): { toasts: IToast[] } => ({
    toasts: [],
  }),
  actions: {
    updateState(payload: ToastPayload, status: TToastStatus) {
      const { text, timeout } = payload;

      const toast = createToast(text, status);

      this.toasts.push(toast);

      setTimeout(() => {
        this.toasts = this.toasts.filter((t) => t.id !== toast.id);
      }, timeout ?? defaultTimeout);
    },
    success(payload: ToastPayload) {
      this.updateState(payload, "success");
    },

    warning(payload: ToastPayload) {
      this.updateState(payload, "warning");
    },

    error(payload: ToastPayload) {
      this.updateState(payload, "error");
    },
  },
});

Написав это, вы только что завершили написание логики для наших тостов! Теперь давайте посмотрим, как мы собираемся построить наше представление!

Создание представления (пользовательский интерфейс)

Сначала давайте обсудим, какие шаги нам нужно предпринять, чтобы создать наше представление:

  1. Создайте SFC (Single File Component), в котором мы будем создавать экземпляр нашего хранилища и использовать его.
  2. Сделайте контейнер для наших тостов, который будет (для нас, но вы можете настроить это позже, даже добавить конкретную опцию) в правом нижнем углу экрана.
  3. Примените стили к контейнеру и настройте стиль и значок каждого всплывающего уведомления в соответствии с его типом.

Так! Мы создадим файл с именем Toaster.vue где-нибудь в нашем приложении и начнем с создания скелета шаблона нашего компонента всплывающего уведомления:

  <Teleport to="body">
      <ul v-if="toastStore.toasts.length" class="toaster__wrapper">
          <li
            v-for="toast in toastStore.toasts"
            class="toaster__list"
            :key="toast.text"
          >
            <Icon
              class="toaster__list-icon"
            />
            <span class="toaster__list-text">
              {{ toast.text }}
            </span>
          </li>
      </ul>
  </Teleport>

Здесь мы используем несколько вещей:

  1. <Teleport>, который позволяет отображать всплывающие уведомления за пределами #app div. Это важно, потому что, как и модальные окна, мы хотим, чтобы все, что создавалось в нашем приложении, было отдельным узлом DOM:

2. Неупорядоченный список <ul>, который отображается только тогда, когда в нашем штате есть тосты. Он оборачивает наши тосты в доступной форме с <li> элементами, отрендеренными с v-for циклом внутри, каждый из которых содержит Icon и span с текстом. Поскольку может быть много способов обработки svg в Vue (размещение их непосредственно в шаблоне, создание с их помощью компонентов, использование NuxtIcon с Nuxt или Iconify с Vue), я использую свой любимый, когда дело доходит до приложения Vite:

<template>
  <component :is="icon" />
</template>

<script setup lang="ts">
import { defineAsyncComponent } from "vue";

// We define what svgs we have
type TIconName = 'toast-error' | 'toast-warning' | 'toast-success'

// We get svg's name from props
const props = defineProps<{ name: TIconName }>();

// We import icon dynamically. Here you would need to define your own path to folder with them
const icon = defineAsyncComponent(
  () => import(`/assets/icons/${props.name}.svg`)
);
</script>

<style scoped lang="scss"></style>

Он прост для понимания, типобезопасен и может использоваться повторно, так что вы тоже можете взять его :) Вы также можете получить svgs из репозитория в конце статьи.

Закончив базовую разметку, давайте посмотрим на стили:

.toaster {
  &__wrapper {
    position: fixed;
    bottom: 3%;
    right: 5%;

    z-index: 100;
  }

  &__list {
    display: flex;
    align-items: center;
    gap: 1rem;

    border-radius: 0.3rem;

    border: 1px solid transparent;

    background-color: white;

    padding: 2.2rem 1.6rem;


    &-icon {
      width: 1.8rem;
      aspect-ratio: 1/1;
    }

    &-text {
      font-size: 1.6rem;
      font-weight: 600;
    }
  }
}

Там я применяю position:fixed со свойствами bottom и right, чтобы позиционировать тосты в правом нижнем углу нашего экрана, где бы мы ни находились, затем я даю ему z-index из 100, чтобы он отображался над большинством (но не над всеми, например, модальными) вещами. В список я добавляю отступы и display: flex с flex-direction:column, чтобы тосты шли вертикально. Далее я также стилизую иконку и текст.

Выбор цвета и значка в зависимости от типа тоста

Чтобы отобразить наши тосты условно, мы будем использовать шаблон map, где ключи объекта подобны карте для сокровищ. Это поможет нам избежать чего-то вроде этого:

if (type === 'a') ...
else if(type === 'b') ...
else if(type === 'c') ...

Карта — это всего лишь простой объект:

const toastColorMap: Record<TToastStatus, string> = {
  warning: "warning",
  error: "error",
  success: "success",
};

const toastIconMap: Record<TToastStatus, string> = {
  error: "toast-error",
  warning: "toast-warning",
  success: "toast-success",
};

Где ключи относятся к типам состояний, которые мы определили в файле useToasterStore, а значения представляют собой простые строки. Здесь мы определяем две карты: одну для класса, применяемого к нашему уведомлению, и одну для имени значка, которое мы предоставим нашему компоненту Icon. Чтобы использовать его в нашем коде, мы настраиваем его следующим образом:

 <li
    v-for="toast in toastStore.toasts"
    :class="['toaster__inner', toastClassMap[toast.status]]"
    :key="toast.text"
>
    <Icon
      :name="toastIconMap[toast.status]"
      class="toaster__inner-icon"
    />
    <span class="toaster__inner-text">
      {{ toast.text }}
    </span>
</li>

Здесь мы используем поле toast.status из каждого элемента нашего цикла v-for для доступа к его значениям на наших картах. Обратите внимание, что в div мы используем массив вместо литералов шаблона, поскольку так легче читать наш код.

Наконец, давайте определим те классы, которые мы используем в карте, в нашей таблице стилей ниже. Для чего воспользуемся css variables, чтобы динамически менять цвета наших тостов и не переписывать каждый раз избыточный код.

 &__inner {
// Assign css variable
    --color: black;
    display: flex;
    align-items: center;
    gap: 1rem;

    border-radius: 0.3rem;

    border: 1px solid transparent;

    background-color: white;

    padding: 2.2rem 1.6rem;

// Use it to color border, text and svg
    border-color: var(--color);
    color: var(--color);
    svg {
      fill: var(--color);
      stroke: var(--color);
    }

// Change it accordingly with applied classes
    &.success {
      --color: green;
    }

    &.warning {
      --color: orange;
    }

    &.error {
      --color: red;
    }
}

В итоге наш Toaster.vue выглядит так:

<template>
  <Teleport to="body">
      <ul v-if="toastStore.toasts.length" class="toaster__wrapper">
          <li
            v-for="toast in toastStore.toasts"
            :class="['toaster__inner', toastClassMap[toast.status]]"
            :key="toast.text"
          >
            <Icon
              :name="toastIconMap[toast.status]"
              class="toaster__inner-icon"
            />

            <span class="toaster__inner-text">
              {{ toast.text }}
            </span>
          </li>
      </ul>
  </Teleport>
</template>

<script setup lang="ts">
import useToasterStore, { TToastStatus } from "@/stores/useToasterStore";
import Icon from "./Icon.vue";

const toastClassMap: Record<TToastStatus, string> = {
  warning: "warning",
  error: "error",
  success: "success",
};

const toastIconMap: Record<TToastStatus, string> = {
  error: "toast-error",
  warning: "toast-warning",
  success: "toast-success",
};
const toastStore = useToasterStore();
</script>

<style scoped lang="scss">
.toaster {
  &__wrapper {
    position: fixed;
    bottom: 3%;
    right: 5%;

    z-index: 100;

    display: flex;
    flex-direction: column;
    gap: 1rem;
  }

  &__inner {
    --color: black;
    display: flex;
    align-items: center;
    gap: 1rem;

    border-radius: 0.3rem;

    border: 1px solid transparent;

    background-color: white;

    padding: 2.2rem 1.6rem;

    border-color: var(--color);
    color: var(--color);
    svg {
      fill: var(--color);
      stroke: var(--color);
    }

    &.success {
      --color: green;
    }

    &.warning {
      --color: orange;
    }

    &.error {
      --color: red;
    }

    &-icon {
      width: 1.8rem;
      aspect-ratio: 1/1;
    }

    &-text {
      font-size: 1.6rem;
      font-weight: 600;
    }
  }
}
</style>

Теперь давайте, наконец, создадим не очень привлекательную кнопку и дождемся тостов!

SomeOtherComponent.vue

<template>
  <Toaster />

  <button @click="successToast">Click me!</button>
</template>

<script setup lang="ts">
import Toaster from "./components/Toaster.vue";
import useToasterStore from "./stores/useToasterStore";

const toasterStore = useToasterStore();

const successToast = () => toasterStore.success({ text: "Yahoooooo!" });
</script>

<style scoped lang="scss"></style>

И, тадаа!

Сколько бы тостов ни было, все они накладываются друг на друга и заменяют тех, кто старше. Хорошая работа!

Добавление анимации

Хотя кажется, что мы закончили, осталась одна важная деталь — анимация. Без него наши тосты кажутся статичными и вялыми, они появляются из ниоткуда, как случайное рекламное уведомление на веб-сайте Wordpress. Давайте исправим это!

Чтобы добавить анимацию к списку элементов, в Vue есть специальный API под названием TransitionGroup. . С его помощью мы можем очень легко определить анимацию, когда элемент входит в наш список и покидает его. Давайте реализуем это в нашем компоненте Toaster.vue:

<template>
  <Teleport to="body">
    <Transition name="toast">
      <div v-if="toastStore.toasts.length" class="toaster__wrapper">
        <TransitionGroup name="toast" tag="ul">
          <li
            v-for="toast in toastStore.toasts"
            :class="['toaster__inner', toastClassMap[toast.status]]"
            :key="toast.text"
          >
            <Icon
              :name="toastIconMap[toast.status]"
              class="toaster__inner-icon"
            />

            <span class="toaster__inner-text">
              {{ toast.text }}
            </span>
          </li>
        </TransitionGroup>
      </div>
    </Transition>
  </Teleport>
</template>

<script setup lang="ts">
import useToasterStore, { TToastStatus } from "@/stores/useToasterStore";
import Icon from "./Icon.vue";

const toastClassMap: Record<TToastStatus, string> = {
  warning: "warning",
  error: "error",
  success: "success",
};

const toastIconMap: Record<TToastStatus, string> = {
  error: "toast-error",
  warning: "toast-warning",
  success: "toast-success",
};
const toastStore = useToasterStore();
</script>

<style scoped lang="scss">
.toast-enter-from,
.toast-leave-to {
  transform: translateX(100%);
  opacity: 0;
}

.toast-enter-active,
.toast-leave-active {
  transition: 0.25s ease all;
}

.toaster {
  &__wrapper {
    position: fixed;
    bottom: 3%;
    right: 5%;

    z-index: 100;

    display: flex;
    flex-direction: column;
    gap: 1rem;
  }

  &__inner {
    --color: black;
    display: flex;
    align-items: center;
    gap: 1rem;

    border-radius: 0.3rem;

    border: 1px solid transparent;

    background-color: white;

    padding: 2.2rem 1.6rem;

    border-color: var(--color);
    color: var(--color);
    svg {
      fill: var(--color);
      stroke: var(--color);
    }

    &.success {
      --color: green;
    }

    &.warning {
      --color: orange;
    }

    &.error {
      --color: red;
    }

    &-icon {
      width: 1.8rem;
      aspect-ratio: 1/1;
    }

    &-text {
      font-size: 1.6rem;
      font-weight: 600;
    }
  }
}
</style>

Посмотрим, что изменилось:

  1. Мы добавили один компонент <Transition> поверх нашей коробки. Мы сделали это, потому что хотим анимировать первый тост, появляющийся в нашем списке, а <TransitionGroup>, к сожалению, этого не делает. Мы даем ему имя toast, которое я объясню позже.
  2. A<TransitionGroup> в качестве элемента нашего ненумерованного списка (мы добавляем tag=”ul" , чтобы отобразить его как <ul>) с тем же именем.
  3. И несколько стилей, которые заставляют наш компонент скользить справа и исчезать при входе и скользить вправо и исчезать при выходе. Под капотом Transition и TransitionGroup эти классы применяются к элементам, которые покидают и входят в DOM, поэтому применение стилей к обоим свойствам равнозначно высказыванию: I want you to animate them the same. Если вы хотите их различать, то можете отдельно настроить стили для классов (подробнее здесь)

Теперь, если мы поднимем наши тосты, посмотрим, что произойдет!

Хлеб движется!

Эй, спасибо, что прочитали эту статью. Вы можете найти код в репозитории здесь: https://github.com/Serpentarius13/toast-tutorial. Не забудьте поставить лайк, если вы что-то узнали, и поделиться этим, если хотите :) Хорошего дня!