Snackbar
Описание
Snackbar предоставляет краткие сообщения о процессах приложения в нижней части экрана.
Подробнее можете прочитать на официальном сайте
Примеры
Компонент
Данный компонент состоит из трёх файлов, каждый из которых можно сохранить в папку src/shared/ui/Snackbar/.... Обратите внимание на необходимые для компонента зависимости: Body и Button.
Также будет необходимо установить @radix-ui/react-toast
yarn add @radix-ui/react-toast
Первый файл - стилизованные radix-ui компоненты. Сохраним в файл Snackbar.tsx
import React from "react"import * as ToastPrimitives from "@radix-ui/react-toast"import { Plus } from "iconoir-react"import Body from "@/shared/typography/Body"import Button from "../Button"import { cn } from "@/lib/cn"const SnackbarProvider = ToastPrimitives.Providerconst SnackbarViewport = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Viewport>,React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>>(({ className, ...props }, forwardedRef) => (<ToastPrimitives.Viewportref={forwardedRef}className={cn("fixed bottom-0 right-0 flex flex-col gap-2 p-6 w-[390px] max-w-[100vw] m-0 list-none z-[2147483647] outline-none",className)}{...props}/>))SnackbarViewport.displayName = "SnackbarViewport"const Snackbar = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Root>,React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>>(({ className, ...props }, forwardedRef) => {return (<ToastPrimitives.Rootref={forwardedRef}className={cn("flex items-center justify-between pl-4 pr-2 shadow-elevation3 rounded bg-inverseSurface text-inverseOnSurface min-h-[48px]",className)}{...props}/>)})Snackbar.displayName = "Snackbar"const SnackbarAction = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Action>,React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>>(({ className, children, ...props }, forwardedRef) => (<ToastPrimitives.Actionref={forwardedRef}className={cn(className)}{...props}asChild><Button appearance="text" className="text-inversePrimary">{children}</Button></ToastPrimitives.Action>))SnackbarAction.displayName = "SnackbarAction"const SnackbarClose = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Close>,React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>>(({ className, ...props }, forwardedRef) => (<ToastPrimitives.Closeref={forwardedRef}className={cn("", className)}aria-label="Close"toast-close=""{...props}><Plus className="rotate-45" /></ToastPrimitives.Close>))SnackbarClose.displayName = "SnackbarClose"const SupportingText = React.forwardRef<React.ElementRef<typeof ToastPrimitives.Description>,React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>>(({ className, children, ...props }, forwardedRef) => (<ToastPrimitives.Descriptionref={forwardedRef}className={cn("text-inverseOnSurface py-3", className)}{...props}><Body>{children}</Body></ToastPrimitives.Description>))SupportingText.displayName = "SupportingText"type SnackbarProps = React.ComponentPropsWithoutRef<typeof Snackbar>export {type SnackbarProps,SnackbarProvider,SnackbarViewport,Snackbar,SupportingText,SnackbarClose,SnackbarAction,}
Второй файл - кастомный react хук, который делегирует создание Snackbar. Сохраним в файл useSnackbar.tsx
Так же можете изменить переменную SNACKBAR_LIMIT под себя. Значение данной переменной - максимально допустимое количество всплывающих уведомлений за раз.
import * as React from "react"import { SnackbarProps } from "./Snackbar"const SNACKBAR_LIMIT = 1export const SNACKBAR_REMOVE_DELAY = 3000type useSnackbarProps = SnackbarProps & {id: stringsupportingText?: React.ReactNodeaction?: stringwithClose?: boolean}const actionTypes = {ADD_SNACKBAR: "ADD_SNACKBAR",UPDATE_SNACKBAR: "UPDATE_SNACKBAR",DISMISS_SNACKBAR: "DISMISS_SNACKBAR",REMOVE_SNACKBAR: "REMOVE_SNACKBAR",} as constlet count = 0const genId = () => {count = (count + 1) % Number.MAX_VALUEreturn count.toString()}type ActionType = typeof actionTypestype Action =| {type: ActionType["ADD_SNACKBAR"]snackbar: useSnackbarProps}| {type: ActionType["UPDATE_SNACKBAR"]snackbar: Partial<useSnackbarProps>}| {type: ActionType["DISMISS_SNACKBAR"]snackbarId?: useSnackbarProps["id"]}| {type: ActionType["REMOVE_SNACKBAR"]snackbarId?: useSnackbarProps["id"]}interface State {snackbars: useSnackbarProps[]}const snackbarTimeouts = new Map<string, ReturnType<typeof setTimeout>>()const addToRemoveQueue = (snackbarId: string) => {if (snackbarTimeouts.has(snackbarId)) {return}const timeout = setTimeout(() => {snackbarTimeouts.delete(snackbarId)dispatch({type: "REMOVE_SNACKBAR",snackbarId: snackbarId,})}, SNACKBAR_REMOVE_DELAY)snackbarTimeouts.set(snackbarId, timeout)}export const reducer = (state: State, action: Action): State => {switch (action.type) {case "ADD_SNACKBAR":return {...state,snackbars: [action.snackbar, ...state.snackbars].slice(0,SNACKBAR_LIMIT),}case "UPDATE_SNACKBAR":return {...state,snackbars: state.snackbars.map((sb) =>sb.id === action.snackbar.id ? { ...sb, ...action.snackbar } : sb),}case "DISMISS_SNACKBAR": {const { snackbarId } = action// ! Side effects ! - This could be extracted into a dismissSnackbar() action,// but I'll keep it here for simplicityif (snackbarId) {addToRemoveQueue(snackbarId)} else {state.snackbars.forEach((snackbar) => {addToRemoveQueue(snackbar.id)})}return {...state,snackbars: state.snackbars.map((sb) =>sb.id === snackbarId || snackbarId === undefined? {...sb,open: false,}: sb),}}case "REMOVE_SNACKBAR":if (action.snackbarId === undefined) {return {...state,snackbars: [],}}return {...state,snackbars: state.snackbars.filter((sb) => sb.id !== action.snackbarId),}}}const listeners: Array<(state: State) => void> = []let memoryState: State = { snackbars: [] }const dispatch = (action: Action) => {memoryState = reducer(memoryState, action)listeners.forEach((listener) => {listener(memoryState)})}interface ISnackbar extends Omit<useSnackbarProps, "id"> {}const addSnackbar = ({ ...props }: ISnackbar) => {const id = genId()const update = (props: useSnackbarProps) =>dispatch({type: "UPDATE_SNACKBAR",snackbar: { ...props, id },})const dismiss = () => dispatch({ type: "DISMISS_SNACKBAR", snackbarId: id })dispatch({type: "ADD_SNACKBAR",snackbar: {...props,id,open: true,onOpenChange: (open) => {if (!open) dismiss()},},})return {id: id,dismiss,update,}}const useSnackbar = () => {const [state, setState] = React.useState<State>(memoryState)React.useEffect(() => {listeners.push(setState)return () => {const index = listeners.indexOf(setState)if (index > -1) {listeners.splice(index, 1)}}}, [state])return {...state,addSnackbar,dismiss: (snackbarId?: string) =>dispatch({ type: "DISMISS_SNACKBAR", snackbarId }),}}export { useSnackbar, addSnackbar }
Третий файл - композиция стилизованных компонентов совместно с состоянием нашего react хука. Сохраним в файл Snackbars.tsx
"use client"import {Snackbar,SnackbarAction,SnackbarClose,SupportingText,SnackbarProvider,SnackbarViewport,} from "./Snackbar"import { useSnackbar, SNACKBAR_REMOVE_DELAY } from "./useSnackbar"import { cn } from "@/lib/cn"const Snackbars = () => {const { snackbars } = useSnackbar()return (<SnackbarProvider duration={SNACKBAR_REMOVE_DELAY}>{snackbars.map(({ id, supportingText, action, withClose, ...props }) => (<Snackbarkey={id}className={cn("px-4", action && "pr-2", withClose && "pr-0")}{...props}><SupportingText>{supportingText}</SupportingText>{action && <SnackbarAction altText="Close">{action}</SnackbarAction>}{withClose && <SnackbarClose className="px-3" />}</Snackbar>))}<SnackbarViewport /></SnackbarProvider>)}export default Snackbars
Использование
После копирования файлов, компонент Snackbars, содержащий всю логику, необходимо добавить на самый верхний уровень вашего приложения
В моём случае, я размещаю данный компонент в компоненте Providers, содержащем все провайдеры для моего приложения:
"use client"import { ReactNode } from "react"import { ThemeProvider } from "next-themes"import Snackbars from "@/shared/ui/Snackbar/Snackbars"const Providers = ({ children }: { children: ReactNode }) => {return (<ThemeProvider attribute="class" defaultTheme="light">{children}<Snackbars /></ThemeProvider>)}export default Providers
После чего, данный компонент добавляется в корневой файл приложения, в моём случае в layout.tsx
export default function RootLayout({children,}: {children: React.ReactNode}) {return (<html lang="en"><body className="min-h-screen w-full flex bg-background"><Providers>{children}</Providers></body></html>)}
Как же вызвать непосредственно Snackbar? Для этого необходим наш второй файл - React хук useSnackbar.tsx
import Button from "@/lib/Button"import { useSnackbar } from "@/lib/Snackbar/useSnackbar"...const { addSnackbar } = useSnackbar()...<Buttonappearance="tonal"onClick={() => {addSnackbar({supportingText: "Snackbar with close and action buttons",action: 'Action',withClose: true})}}>With close and action</Button>
Хук принимает в качестве аргумента объект с тремя свойствами:
supportingText - String. Отвечает за текст, находящимся на Snackbar. Является обязательным полем.
action - String. Текст для кнопки - действия.
withClose - Boolean. Если значение === true, то в правой части Snackbar будет крестик для закрытия. По умолчанию false.