import { parseApiError } from '../runtime'; const DISMISS_TIME = 4000; const ACTION_DISMISS_TIME = 6000; // Один таймер на логическое уведомление. Снекбар физически переезжает между // диалогом (ui-popup) и общим макетом (default.vue), поэтому таймер не может жить // в компоненте — при перемонтировании он сбрасывался бы заново. Идентификатор // setTimeout создаётся только в браузере, на сервере он не заводится. let dismissTimer: null | ReturnType = null; // Остаток автозакрытия и момент его запуска — чтобы навести на снекбар, прочитать // сообщение и дожать undo: на ховере/фокусе таймер встаёт на паузу, после уходя // курсора он добирает недоигранное время, а не начинает отсчёт заново. let dismissRemaining = 0; let dismissStartedAt = 0; // Колбэк кнопки лежит рядом с таймером, а не в useState: функция не сериализуется // в payload, да и показывается снекбар всегда с клиента. let actionHandler: null | (() => unknown) = null; // Отложенное действие: срабатывает, когда снекбар закрылся сам и отменять уже нечего. // Так работает оптимистичное удаление — из списка убрали сразу, запрос уходит по закрытию. let dismissHandler: null | (() => unknown) = null; export interface ISnackbarOptions { /** Имя иконки в кружке слева. */ icon?: string; /** Часть сообщения, выводимая жирным — имя канала, плейлиста, видео. */ highlight?: string; /** i18n-ключ внутри `snackbar.*` для подписи кнопки, по умолчанию `undo`. */ actionText?: string; /** Действие кнопки — как правило откат того, о чём сообщает снекбар. */ onAction?: () => unknown; /** * Действие на закрытие снекбара без нажатия кнопки: окно отмены истекло, значит * пора отправлять то, что до этого показали оптимистично. Нажатый undo его отменяет. */ onDismiss?: () => unknown; } /** Управляет состоянием snackbar-уведомления: показ сообщения, кнопка отмены действия, сброс, иконка и таймер автозакрытия. */ export const useSnackbar = () => { const snackbarIcon = useState('snackbar-icon', () => 'check'); const snackbarText = useState('snackbar-text', () => ''); const snackbarHighlight = useState('snackbar-highlight', () => ''); const snackbarActionText = useState('snackbar-action-text', () => ''); // Снекбар физически переезжает между открытым попапом и макетом: диалог лежит в top // layer, поэтому снекбар из макета оказался бы под ним и под блюром. Счётчик, а не флаг — // при закрытии вложенного попапа снекбар должен остаться в том, что ещё открыт. const popupDepth = useState('snackbar-popup-depth', () => 0); const isSnackBarInPopup = computed(() => popupDepth.value > 0); function enterPopup() { popupDepth.value++; } function leavePopup() { popupDepth.value = Math.max(0, popupDepth.value - 1); } function clearTimer() { if (dismissTimer) { clearTimeout(dismissTimer); dismissTimer = null; } } function scheduleDismiss(ms: number) { clearTimer(); dismissRemaining = ms; if (import.meta.client) { dismissStartedAt = Date.now(); dismissTimer = setTimeout(resetSnackbar, ms); } } /** Пауза автозакрытия, пока на снекбаре курсор или фокус. */ function pauseDismiss() { if (!dismissTimer) return; dismissRemaining -= Date.now() - dismissStartedAt; clearTimer(); } /** Возврат к отсчёту с того места, где он остановился. */ function resumeDismiss() { if (dismissTimer || !snackbarText.value) return; if (dismissRemaining <= 0) { resetSnackbar(); return; } scheduleDismiss(dismissRemaining); } function show(message: string, options: ISnackbarOptions = {}) { // Новый снекбар вытесняет прежний, вместе с его окном отмены: отложенное // действие того выполняем сразу, иначе оно потерялось бы вместе с таймером. const previousDismiss = dismissHandler; actionHandler = options.onAction ?? null; dismissHandler = options.onDismiss ?? null; snackbarIcon.value = options.icon ?? 'check'; snackbarText.value = message; snackbarHighlight.value = options.highlight ?? ''; snackbarActionText.value = actionHandler ? options.actionText ?? 'undo' : ''; scheduleDismiss(actionHandler ? ACTION_DISMISS_TIME : DISMISS_TIME); previousDismiss?.(); } /** Ошибка отличается от обычного сообщения только иконкой — вид у снекбара один. */ function showError(message: string, options: ISnackbarOptions = {}) { show(message, { icon: 'close', ...options }); } function setErrorState(error: any) { showError(parseApiError(error)); } /** Запускает действие кнопки и закрывает снекбар, чтобы отменить нельзя было дважды. */ function runSnackbarAction() { const handler = actionHandler; // Отмена снимает и отложенное действие: пользователь передумал, отправлять нечего. dismissHandler = null; resetSnackbar(); handler?.(); } function resetSnackbar() { const dismiss = dismissHandler; clearTimer(); dismissRemaining = 0; actionHandler = null; dismissHandler = null; snackbarIcon.value = 'check'; snackbarText.value = ''; snackbarHighlight.value = ''; snackbarActionText.value = ''; dismiss?.(); } return { snackbarIcon, snackbarText, snackbarHighlight, snackbarActionText, isSnackBarInPopup, enterPopup, leavePopup, show, showError, setErrorState, runSnackbarAction, resetSnackbar, pauseDismiss, resumeDismiss, }; };