diff --git a/packages/vkui/docs/icons-overview/IconsOverview.tsx b/packages/vkui/docs/icons-overview/IconsOverview.tsx index cd1f59b061e..770d3f159d6 100644 --- a/packages/vkui/docs/icons-overview/IconsOverview.tsx +++ b/packages/vkui/docs/icons-overview/IconsOverview.tsx @@ -13,8 +13,8 @@ import { ConfigProvider, Flex, Mark, + snackbarManager, Tooltip, - useSnackbarManager, } from '../../src'; import { Keys, pressedKey } from '../../src/lib/accessibility'; import { OverviewLayout } from '../common/components/OverviewLayout'; @@ -28,6 +28,8 @@ const SIZES_OPTIONS: ChipOption[] = ICON_SIZES.map((size) => ({ label: size, })); +snackbarManager.setLimit(1); + const filterConfig = (config: ConfigData[], query: string, sizes: string[]) => { if (!query && sizes.length === SIZES_OPTIONS.length) { return config; @@ -52,9 +54,6 @@ const filterConfig = (config: ConfigData[], query: string, sizes: string[]) => { }; const IconsOverview = () => { - const [snackbarApi, contextHolder] = useSnackbarManager({ - limit: 1, - }); const [selectedSizes, setSelectedSizes] = useState(SIZES_OPTIONS); const rootRef = useRef(null); @@ -68,32 +67,29 @@ const IconsOverview = () => { [selectedSizes], ); - const onIconClick = useCallback( - (iconName: string) => { - const iconCode = `<${iconName} />`; + const onIconClick = useCallback((iconName: string) => { + const iconCode = `<${iconName} />`; - navigator.clipboard - .writeText(iconCode) - .then(() => { - snackbarApi.open({ - before: ( - - - - ), - children: ( - <> - {iconCode} скопировано! - - ), - style: { maxInlineSize: 'unset', inlineSize: 'fit-content' }, - placement: 'top-end', - }); - }) - .catch(noop); - }, - [snackbarApi], - ); + navigator.clipboard + .writeText(iconCode) + .then(() => { + snackbarManager.open({ + before: ( + + + + ), + children: ( + <> + {iconCode} скопировано! + + ), + style: { maxInlineSize: 'unset', inlineSize: 'fit-content' }, + placement: 'top-end', + }); + }) + .catch(noop); + }, []); const onKeyDown = useCallback( (e: KeyboardEvent, iconName: string) => { @@ -157,7 +153,6 @@ const IconsOverview = () => { } /> - {contextHolder} ); diff --git a/packages/vkui/src/hooks/useSnackbarManager/components/SnackbarManagerHolder.tsx b/packages/vkui/src/hooks/useSnackbarManager/components/SnackbarManagerHolder.tsx new file mode 100644 index 00000000000..3616af52de1 --- /dev/null +++ b/packages/vkui/src/hooks/useSnackbarManager/components/SnackbarManagerHolder.tsx @@ -0,0 +1,75 @@ +'use client'; + +import * as React from 'react'; +import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; +import { + getSnackbarManagerInternals, + snackbarManager, + type SnackbarManagerConfig, +} from '../snackbarManager'; +import type { SnackbarManagerNS } from '../types'; +import { SnackbarHolder } from './SnackbarHolder'; + +export const SnackbarManagerHolder: React.FC = ({ + manager = snackbarManager, + limit, + queueStrategy, + offsetYStart, + offsetYEnd, + zIndex, +}) => { + const internals = getSnackbarManagerInternals(manager); + + React.useEffect(() => { + internals.registerHolder(); + return () => { + internals.unregisterHolder(); + }; + }, [internals]); + + useIsomorphicLayoutEffect(() => { + if (limit !== undefined) { + manager.setLimit(limit); + } + }, [manager, limit]); + + useIsomorphicLayoutEffect(() => { + if (queueStrategy !== undefined) { + manager.setQueueStrategy(queueStrategy); + } + }, [manager, queueStrategy]); + + useIsomorphicLayoutEffect(() => { + if (offsetYStart !== undefined) { + manager.setOffsetYStart(offsetYStart); + } + }, [manager, offsetYStart]); + + useIsomorphicLayoutEffect(() => { + if (offsetYEnd !== undefined) { + manager.setOffsetYEnd(offsetYEnd); + } + }, [manager, offsetYEnd]); + + useIsomorphicLayoutEffect(() => { + if (zIndex !== undefined) { + manager.setZIndex(zIndex); + } + }, [manager, zIndex]); + + const configStore = React.useSyncExternalStore( + internals.subscribeConfig, + internals.getConfig, + internals.getConfig, + ); + + return ( + + ); +}; diff --git a/packages/vkui/src/hooks/useSnackbarManager/constants.ts b/packages/vkui/src/hooks/useSnackbarManager/constants.ts new file mode 100644 index 00000000000..0a48ca0dbe6 --- /dev/null +++ b/packages/vkui/src/hooks/useSnackbarManager/constants.ts @@ -0,0 +1,2 @@ +export const DEFAULT_LIMIT = 4; +export const DEFAULT_QUEUE_STRATEGY = 'shift'; diff --git a/packages/vkui/src/hooks/useSnackbarManager/helpers/createSnackbarActions.ts b/packages/vkui/src/hooks/useSnackbarManager/helpers/createSnackbarActions.ts new file mode 100644 index 00000000000..7b39d4633bf --- /dev/null +++ b/packages/vkui/src/hooks/useSnackbarManager/helpers/createSnackbarActions.ts @@ -0,0 +1,133 @@ +import type * as React from 'react'; +import { randomUUID } from '../../../lib/randomUUID'; +import { SnackbarWrapper } from '../components/SnackbarWrapper'; +import { + type CommonOnOpenPayload, + type CustomSnackbar, + type SnackbarApi, + type SnackbarPlacement, +} from '../types'; +import type { SnackbarStore } from './createSnackbarStore'; + +const resolveMobilePlacement = ( + placement: SnackbarPlacement, +): Extract => { + if (placement.startsWith('top')) { + return 'top'; + } + return 'bottom-start'; +}; + +const resolveCustomPayload = ( + props: + | CustomSnackbar.Payload + | React.ComponentType>, +): CustomSnackbar.Payload => { + if ('component' in props) { + return props; + } + return { + component: props, + }; +}; + +export type SnackbarActionsConfig = { + getLimit: () => number; + getQueueStrategy: () => SnackbarApi.QueueStrategy; + getIsDesktop: () => boolean; +}; + +export type SnackbarActions = Pick< + SnackbarApi.Api, + 'open' | 'openCustom' | 'update' | 'close' | 'closeAll' +>; + +export const createSnackbarActions = ( + store: SnackbarStore, + config: SnackbarActionsConfig, +): SnackbarActions => { + const update: SnackbarApi.Api['update'] = (id, updateConfig) => { + store.updateSnackbar(id, updateConfig); + }; + + const close: SnackbarApi.Api['close'] = (id) => { + if (store.showedSnackbars.has(id)) { + store.closeSnackbar(id); + } else { + store.removeSnackbar(id); + } + }; + + const onOpenSnackbarImpl = (item: CommonOnOpenPayload): SnackbarApi.OpenReturn => { + const placement: SnackbarPlacement = item.snackbarProps?.placement || 'bottom-start'; + const resolvedPlacement = config.getIsDesktop() ? placement : resolveMobilePlacement(placement); + + const limit = config.getLimit(); + const placementSnackbars = store.getSnackbarsByPlacement(resolvedPlacement, limit); + + const withOverflow = + config.getQueueStrategy() === 'shift' && placementSnackbars.length >= limit; + + let resolvePromise: () => void; + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const id = item.id; + + if (withOverflow) { + store.closeOverflowedSnackbars(placementSnackbars); + } + + store.addSnackbar({ + ...item, + id, + snackbarProps: { + ...item.snackbarProps, + id, + placement: resolvedPlacement, + onClosed: () => { + resolvePromise!(); + item.snackbarProps?.onClosed?.(); + }, + }, + }); + + return { + id, + close: () => close(id), + update: (newProps) => update(id, newProps), + onClose: (resolve?: () => R) => { + return promise.then(resolve); + }, + }; + }; + + const openCustom: SnackbarApi.Api['openCustom'] = (openConfig) => { + const resolvedProps = resolveCustomPayload(openConfig); + const id = resolvedProps.id || randomUUID(); + + return onOpenSnackbarImpl({ + id, + component: resolvedProps.component, + snackbarProps: resolvedProps.baseProps, + additionalProps: resolvedProps.additionalProps, + close: () => close(id), + update: (newProps) => update(id, newProps), + }); + }; + + const open: SnackbarApi.Api['open'] = (openConfig) => { + return openCustom({ + id: openConfig.id, + component: SnackbarWrapper, + baseProps: openConfig, + }); + }; + + const closeAll: SnackbarApi.Api['closeAll'] = () => { + store.closeAll(store.showedSnackbars); + }; + + return { open, openCustom, update, close, closeAll }; +}; diff --git a/packages/vkui/src/hooks/useSnackbarManager/helpers/useIsDesktop.ts b/packages/vkui/src/hooks/useSnackbarManager/helpers/useIsDesktop.ts index 4400106d4cc..e068abfd992 100644 --- a/packages/vkui/src/hooks/useSnackbarManager/helpers/useIsDesktop.ts +++ b/packages/vkui/src/hooks/useSnackbarManager/helpers/useIsDesktop.ts @@ -1,7 +1,26 @@ import * as React from 'react'; +import { MEDIA_QUERIES } from '../../../lib/adaptivity'; import { useDOM } from '../../../lib/dom'; import { useMediaQueries } from '../../useMediaQueries'; +/** + * Определяет desktop-режим по окну без хуков. + * Desktop: (smallTabletPlus and pointer: fine) or (smallTabletPlus and mediumHeight) + * Совпадает с логикой useIsDesktop(). + */ +export function getIsDesktop(window: Window | null | undefined): boolean { + if (!window) { + return false; + } + // eslint-disable-next-line no-restricted-properties + const smallTabletPlus = window.matchMedia(MEDIA_QUERIES.SMALL_TABLET_PLUS).matches; + // eslint-disable-next-line no-restricted-properties + const mediumHeight = window.matchMedia(MEDIA_QUERIES.MEDIUM_HEIGHT).matches; + // eslint-disable-next-line no-restricted-properties + const pointerFine = window.matchMedia('(pointer: fine)').matches; + return smallTabletPlus && (pointerFine || mediumHeight); +} + /** * Хук для определения desktop режима. * Desktop: (smallTabletPlus and pointer: fine) or (smallTabletPlus and mediumHeight) diff --git a/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarActionsWithStore.ts b/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarActionsWithStore.ts index d2471fc0262..08bf27b7516 100644 --- a/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarActionsWithStore.ts +++ b/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarActionsWithStore.ts @@ -1,31 +1,7 @@ import * as React from 'react'; -import { randomUUID } from '../../../lib/randomUUID'; -import { SnackbarWrapper } from '../components/SnackbarWrapper'; -import type { CommonOnOpenPayload, CustomSnackbar, SnackbarApi, SnackbarPlacement } from '../types'; +import { createSnackbarActions, type SnackbarActions } from './createSnackbarActions'; import type { SnackbarStore } from './createSnackbarStore'; -const resolveMobilePlacement = ( - placement: SnackbarPlacement, -): Extract => { - if (placement.startsWith('top')) { - return 'top'; - } - return 'bottom-start'; -}; - -const resolveProps = ( - props: - | CustomSnackbar.Payload - | React.ComponentType>, -): CustomSnackbar.Payload => { - if ('component' in props) { - return props; - } - return { - component: props, - }; -}; - export type UseSnackbarActionsWithStoreProps = { store: SnackbarStore; limit: number; @@ -38,7 +14,7 @@ export const useSnackbarActionsWithStore = ({ limit, queueStrategy, isDesktop, -}: UseSnackbarActionsWithStoreProps) => { +}: UseSnackbarActionsWithStoreProps): SnackbarActions => { const limitRef = React.useRef(limit); const queueStrategyRef = React.useRef(queueStrategy); const isDesktopRef = React.useRef(isDesktop); @@ -55,111 +31,13 @@ export const useSnackbarActionsWithStore = ({ isDesktopRef.current = isDesktop; }, [isDesktop]); - const update: SnackbarApi.Api['update'] = React.useCallback( - (id, config) => { - store.updateSnackbar(id, config); - }, - [store], - ); - - const close: SnackbarApi.Api['close'] = React.useCallback( - (id) => { - if (store.showedSnackbars.has(id)) { - store.closeSnackbar(id); - } else { - store.removeSnackbar(id); - } - }, - [store], - ); - - const onOpenSnackbarImpl = React.useCallback( - (item: CommonOnOpenPayload): SnackbarApi.OpenReturn => { - const placement: SnackbarPlacement = item.snackbarProps?.placement || 'bottom-start'; - const resolvedPlacement = isDesktopRef.current - ? placement - : resolveMobilePlacement(placement); - - const placementSnackbars = store.getSnackbarsByPlacement(resolvedPlacement, limitRef.current); - - const withOverflow = - queueStrategyRef.current === 'shift' && placementSnackbars.length >= limitRef.current; - - let resolvePromise: () => void; - const promise = new Promise((resolve) => { - resolvePromise = resolve; - }); - - const id = item.id; - - if (withOverflow) { - store.closeOverflowedSnackbars(placementSnackbars); - } - - store.addSnackbar({ - ...item, - id, - snackbarProps: { - ...item.snackbarProps, - id, - placement: resolvedPlacement, - onClosed: () => { - resolvePromise!(); - item.snackbarProps?.onClosed?.(); - }, - }, - }); - - return { - id, - close: () => close(id), - update: (newProps) => update(id, newProps), - onClose: (resolve?: () => R) => { - return promise.then(resolve); - }, - }; - }, - [close, store, update], - ); - - const openCustom: SnackbarApi.Api['openCustom'] = React.useCallback( - (config) => { - const resolvedProps = resolveProps(config); - - const id = resolvedProps.id || randomUUID(); - - return onOpenSnackbarImpl({ - id, - component: resolvedProps.component, - snackbarProps: resolvedProps.baseProps, - additionalProps: resolvedProps.additionalProps, - close: () => close(id), - update: (newProps) => update(id, newProps), - }); - }, - [close, onOpenSnackbarImpl, update], - ); - - const open: SnackbarApi.Api['open'] = React.useCallback( - (config) => { - return openCustom({ - id: config.id, - component: SnackbarWrapper, - baseProps: config, - }); - }, - [openCustom], - ); - - const closeAllSnackbars: SnackbarApi.Api['closeAll'] = React.useCallback(() => { - store.closeAll(store.showedSnackbars); + return React.useMemo(() => { + /* eslint-disable react-hooks/refs -- refs читаются в колбэках API (open/close), не при рендере */ + return createSnackbarActions(store, { + getLimit: () => limitRef.current, + getQueueStrategy: () => queueStrategyRef.current, + getIsDesktop: () => isDesktopRef.current, + }); + /* eslint-enable react-hooks/refs */ }, [store]); - - return { - open, - openCustom, - update, - close, - closeAll: closeAllSnackbars, - }; }; diff --git a/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarConfig.ts b/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarConfig.ts index 089ee5320fe..d2ad1b7cc12 100644 --- a/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarConfig.ts +++ b/packages/vkui/src/hooks/useSnackbarManager/helpers/useSnackbarConfig.ts @@ -1,9 +1,8 @@ import * as React from 'react'; import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect'; +import { DEFAULT_LIMIT, DEFAULT_QUEUE_STRATEGY } from '../constants'; import type { UseSnackbar } from '../types'; -const DEFAULT_MAX_VISIBLE_SNACKBARS = 4; - export type UseSnackbarConfigReturn = { limit: number; queueStrategy: 'queue' | 'shift'; @@ -19,8 +18,8 @@ export type UseSnackbarConfigReturn = { export const useSnackbarConfig = (params: UseSnackbar.Props = {}): UseSnackbarConfigReturn => { const { - limit: limitProp = DEFAULT_MAX_VISIBLE_SNACKBARS, - queueStrategy: queueStrategyProp = 'shift', + limit: limitProp = DEFAULT_LIMIT, + queueStrategy: queueStrategyProp = DEFAULT_QUEUE_STRATEGY, offsetYStart: offsetYStartProp, offsetYEnd: offsetYEndProp, zIndex: zIndexProp, diff --git a/packages/vkui/src/hooks/useSnackbarManager/index.ts b/packages/vkui/src/hooks/useSnackbarManager/index.ts index 129cb55d238..60444448a3a 100644 --- a/packages/vkui/src/hooks/useSnackbarManager/index.ts +++ b/packages/vkui/src/hooks/useSnackbarManager/index.ts @@ -1,2 +1,4 @@ export { useSnackbarManager } from './useSnackbarManager'; -export type { SnackbarApi, CustomSnackbar, UseSnackbar } from './types'; +export { SnackbarManagerHolder } from './components/SnackbarManagerHolder'; +export { snackbarManager, createSnackbarManager } from './snackbarManager'; +export type { SnackbarApi, CustomSnackbar, UseSnackbar, SnackbarManagerNS } from './types'; diff --git a/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.test.tsx b/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.test.tsx new file mode 100644 index 00000000000..b0147210efa --- /dev/null +++ b/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.test.tsx @@ -0,0 +1,195 @@ +import { act, render, screen, waitFor } from '@testing-library/react'; +import { waitCSSKeyframesAnimation, withFakeTimers } from '../../testing/utils'; +import { SnackbarManagerHolder } from './components/SnackbarManagerHolder'; +import { + AUTO_MOUNT_HOLDER_ATTR, + createSnackbarManager, + getSnackbarManagerInternals, + snackbarManager, +} from './snackbarManager'; + +const AUTO_MOUNT_HOLDER_SELECTOR = `[${AUTO_MOUNT_HOLDER_ATTR}]`; + +describe('snackbarManager (imperative API)', () => { + afterEach(() => { + act(() => { + snackbarManager.closeAll(); + }); + document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)?.remove(); + }); + + it('auto-mounts SnackbarManagerHolder in document.body on first open()', async () => { + expect(document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)).not.toBeInTheDocument(); + + act(() => { + snackbarManager.open({ children: 'Auto-mounted snackbar' }); + }); + + await waitFor(() => { + expect(screen.getByText('Auto-mounted snackbar')).toBeInTheDocument(); + }); + + expect(document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)).toBeInTheDocument(); + }); + + it('opens snackbar when SnackbarManagerHolder is mounted', async () => { + render(); + + act(() => { + snackbarManager.open({ children: 'Global snackbar' }); + }); + + expect(screen.getByText('Global snackbar')).toBeInTheDocument(); + }); + + it( + 'closes snackbar by id', + withFakeTimers(async () => { + render(); + + let id: string | undefined; + act(() => { + const result = snackbarManager.open({ + 'children': 'To close', + 'data-testid': 'snackbar-to-close', + }); + id = result.id; + }); + + const snackbarEl = screen.getByTestId('snackbar-to-close'); + expect(snackbarEl).toBeInTheDocument(); + + act(() => { + snackbarManager.close(id!); + }); + + const alert = snackbarEl.querySelector('[role="alert"]') ?? snackbarEl; + await waitCSSKeyframesAnimation(alert as HTMLElement, { + runOnlyPendingTimers: true, + }); + expect(screen.queryByTestId('snackbar-to-close')).not.toBeInTheDocument(); + }), + ); + + it( + 'closeAll closes all snackbars', + withFakeTimers(async () => { + render(); + + act(() => { + snackbarManager.open({ children: 'First' }); + snackbarManager.open({ children: 'Second' }); + }); + + expect(screen.getByText('First')).toBeInTheDocument(); + expect(screen.getByText('Second')).toBeInTheDocument(); + + act(() => { + snackbarManager.closeAll(); + }); + + const alerts = screen.getAllByRole('alert'); + await Promise.all( + alerts.map((alert) => waitCSSKeyframesAnimation(alert, { runOnlyPendingTimers: true })), + ); + + expect(screen.queryByText('First')).not.toBeInTheDocument(); + expect(screen.queryByText('Second')).not.toBeInTheDocument(); + }), + ); + + it('update changes snackbar content', async () => { + render(); + + let id: string | undefined; + act(() => { + const result = snackbarManager.open({ children: 'Initial text' }); + id = result.id; + }); + + expect(screen.getByText('Initial text')).toBeInTheDocument(); + + act(() => { + snackbarManager.update(id!, { children: 'Updated text' }); + }); + + expect(screen.queryByText('Initial text')).not.toBeInTheDocument(); + expect(screen.getByText('Updated text')).toBeInTheDocument(); + }); + + it('applies SnackbarManagerHolder props and shows snackbar', async () => { + render(); + + act(() => { + snackbarManager.open({ children: 'With zIndex' }); + }); + + expect(screen.getByText('With zIndex')).toBeInTheDocument(); + }); +}); + +describe('createSnackbarManager', () => { + it('creates independent manager instance', async () => { + const customManager = createSnackbarManager({ limit: 1 }); + + render(); + + act(() => { + customManager.open({ children: 'From custom manager' }); + }); + + expect(screen.getByText('From custom manager')).toBeInTheDocument(); + + act(() => { + customManager.closeAll(); + }); + }); + + it( + 'custom manager and global snackbarManager are independent', + withFakeTimers(async () => { + const customManager = createSnackbarManager(); + + render( + <> + + + , + ); + + act(() => { + snackbarManager.open({ children: 'Global' }); + customManager.open({ children: 'Custom' }); + }); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.getByText('Custom')).toBeInTheDocument(); + + act(() => { + customManager.closeAll(); + }); + + const customAlerts = screen + .getAllByRole('alert') + .filter((el) => el.textContent?.includes('Custom')); + await Promise.all( + customAlerts.map((alert) => + waitCSSKeyframesAnimation(alert, { runOnlyPendingTimers: true }), + ), + ); + + expect(screen.getByText('Global')).toBeInTheDocument(); + expect(screen.queryByText('Custom')).not.toBeInTheDocument(); + + act(() => { + snackbarManager.closeAll(); + }); + }), + ); +}); + +describe('getSnackbarManagerInternals', () => { + it('throws when passed non-manager object', () => { + expect(() => getSnackbarManagerInternals({} as any)).toThrow('VKUI'); + }); +}); diff --git a/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.ts b/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.ts new file mode 100644 index 00000000000..c731409aaaa --- /dev/null +++ b/packages/vkui/src/hooks/useSnackbarManager/snackbarManager.ts @@ -0,0 +1,194 @@ +import * as React from 'react'; +import { createRoot } from 'react-dom/client'; +import { getDOM } from '../../lib/dom'; +import { DEFAULT_LIMIT, DEFAULT_QUEUE_STRATEGY } from './constants'; +import { createSnackbarActions } from './helpers/createSnackbarActions'; +import { createSnackbarStore, type SnackbarStore } from './helpers/createSnackbarStore'; +import { getIsDesktop } from './helpers/useIsDesktop'; +import type { SnackbarApi, SnackbarManagerNS } from './types'; + +export const AUTO_MOUNT_HOLDER_ATTR = 'data-vkui-snackbar-manager-holder'; + +type SnackbarManagerRoot = ReturnType; +const autoHolderRoots = new WeakMap(); + +export type SnackbarManagerConfig = { + limit: number; + queueStrategy: SnackbarApi.QueueStrategy; + offsetYStart: SnackbarApi.OffsetY | undefined; + offsetYEnd: SnackbarApi.OffsetY | undefined; + zIndex: number | string | undefined; +}; + +type SnackbarManagerInternals = { + store: SnackbarStore; + getConfig: () => SnackbarManagerConfig; + subscribeConfig: (listener: () => void) => () => void; + registerHolder: () => void; + unregisterHolder: () => void; +}; + +const internalsMap = new WeakMap(); + +/** @internal */ +export function getSnackbarManagerInternals(manager: SnackbarApi.Api): SnackbarManagerInternals { + const internals = internalsMap.get(manager); + if (!internals) { + throw new Error( + process.env.NODE_ENV === 'production' + ? 'VKUI: invalid SnackbarManager' + : 'VKUI: the provided manager was not created by createSnackbarManager()', + ); + } + return internals; +} + +export function createSnackbarManager( + options: SnackbarManagerNS.Options = {}, +): SnackbarManagerNS.Instance { + const store = createSnackbarStore(); + + const { document, window } = getDOM(); + + let config: SnackbarManagerConfig = { + limit: options.limit ?? DEFAULT_LIMIT, + queueStrategy: options.queueStrategy ?? DEFAULT_QUEUE_STRATEGY, + offsetYStart: options.offsetYStart, + offsetYEnd: options.offsetYEnd, + zIndex: options.zIndex, + }; + + const configListeners = new Set<() => void>(); + + const notifyConfig = () => { + configListeners.forEach((listener) => listener()); + }; + + const actions = createSnackbarActions(store, { + getLimit: () => config.limit, + getQueueStrategy: () => config.queueStrategy, + getIsDesktop: () => getIsDesktop(window), + }); + + let holderCount = 0; + let mountCallback: (() => void) | null = null; + let unmountCallback: (() => void) | null = null; + + const unmountHolder = () => { + if (unmountCallback) { + unmountCallback(); + return; + } + }; + + const ensureHolderMounted = () => { + if (typeof document === 'undefined') { + return; + } + if (holderCount > 0) { + return; + } + if (mountCallback) { + const cb = mountCallback; + mountCallback = null; + cb(); + } + }; + + const updateConfig = (newConfig: SnackbarManagerConfig) => { + config = newConfig; + notifyConfig(); + }; + + const instance: SnackbarManagerNS.Instance = { + open: (config) => { + ensureHolderMounted(); + return actions.open(config); + }, + openCustom: (config) => { + ensureHolderMounted(); + return actions.openCustom(config); + }, + update: actions.update, + close: actions.close, + closeAll: actions.closeAll, + setLimit: (count) => updateConfig({ ...config, limit: count }), + setQueueStrategy: (strategy) => updateConfig({ ...config, queueStrategy: strategy }), + setOffsetYStart: (offset) => updateConfig({ ...config, offsetYStart: offset }), + setOffsetYEnd: (offset) => updateConfig({ ...config, offsetYEnd: offset }), + setZIndex: (z) => updateConfig({ ...config, zIndex: z }), + setMountCallback: (cb) => { + mountCallback = cb; + }, + setUnmountCallback: (cb) => { + unmountCallback = cb; + }, + unmount: unmountHolder, + }; + + internalsMap.set(instance, { + store, + getConfig: () => config, + subscribeConfig: (listener) => { + configListeners.add(listener); + return () => configListeners.delete(listener); + }, + registerHolder: () => { + holderCount += 1; + }, + unregisterHolder: () => { + holderCount = Math.max(0, holderCount - 1); + }, + }); + + return instance; +} + +function getDefaultMountCallback(): () => void { + return function mount() { + const { document } = getDOM(); + if (typeof document === 'undefined') { + return; + } + // Динамический импорт нужен, чтобы предотвратить циклическую зависимость + void import('./components/SnackbarManagerHolder').then(({ SnackbarManagerHolder }) => { + const container = document.createElement('div'); + container.setAttribute(AUTO_MOUNT_HOLDER_ATTR, ''); + document.body.appendChild(container); + const root = createRoot(container); + + autoHolderRoots.set(container, root); + + root.render(React.createElement(SnackbarManagerHolder)); + }); + }; +} + +function getDefaultUnmountCallback(): () => void { + return function unmount() { + const { document } = getDOM(); + if (typeof document === 'undefined') { + return; + } + // eslint-disable-next-line no-restricted-properties + const container = document.querySelector(`[${AUTO_MOUNT_HOLDER_ATTR}]`); + if (!container) { + return; + } + + const root = autoHolderRoots.get(container); + if (root) { + root.unmount(); + autoHolderRoots.delete(container); + } + + if (container.parentNode) { + container.parentNode.removeChild(container); + } + }; +} + +export const snackbarManager: SnackbarManagerNS.Instance = createSnackbarManager(); + +snackbarManager.setMountCallback(getDefaultMountCallback()); +snackbarManager.setUnmountCallback(getDefaultUnmountCallback()); diff --git a/packages/vkui/src/hooks/useSnackbarManager/types.ts b/packages/vkui/src/hooks/useSnackbarManager/types.ts index d7b470ea6a6..280891b7f68 100644 --- a/packages/vkui/src/hooks/useSnackbarManager/types.ts +++ b/packages/vkui/src/hooks/useSnackbarManager/types.ts @@ -129,5 +129,38 @@ export type CommonOnOpenPayload = Pick void) | null) => void; + /** + * Устанавливает колбэк, который будет вызван для демонтирования контейнера снекбаров. + * Передайте `null`, чтобы вернуть поведение по умолчанию. + */ + setUnmountCallback: (callback: (() => void) | null) => void; + /** + * Принудительно размонтирует контейнер снекбаров. + */ + unmount: () => void; + }; + + export interface HolderProps extends UseSnackbar.Props { + /** + * Экземпляр менеджера. По умолчанию используется глобальный `snackbarManager`. + */ + manager?: Instance; + } +} + export { type SnackbarPlacement } from '../../components/Snackbar/types'; export { SnackbarProps }; diff --git a/packages/vkui/src/index.ts b/packages/vkui/src/index.ts index b4acd8cbab8..32a138f1905 100644 --- a/packages/vkui/src/index.ts +++ b/packages/vkui/src/index.ts @@ -145,7 +145,17 @@ export type { ScreenSpinnerProps } from './components/ScreenSpinner/ScreenSpinne export type { ScreenSpinnerContextProps } from './components/ScreenSpinner/context'; export { Snackbar } from './components/Snackbar/Snackbar'; export { useSnackbarManager } from './hooks/useSnackbarManager'; -export type { SnackbarApi, CustomSnackbar, UseSnackbar } from './hooks/useSnackbarManager'; +export { + SnackbarManagerHolder, + snackbarManager, + createSnackbarManager, +} from './hooks/useSnackbarManager'; +export type { + SnackbarApi, + CustomSnackbar, + UseSnackbar, + SnackbarManagerNS, +} from './hooks/useSnackbarManager'; export type { SnackbarProps, SnackbarBasicProps } from './components/Snackbar/Snackbar'; export { Tooltip } from './components/Tooltip/Tooltip'; export { useTooltip } from './components/Tooltip/useTooltip'; diff --git a/packages/vkui/src/lib/dom.tsx b/packages/vkui/src/lib/dom.tsx index cd4a667ba36..a5890e57f8d 100644 --- a/packages/vkui/src/lib/dom.tsx +++ b/packages/vkui/src/lib/dom.tsx @@ -35,7 +35,7 @@ export interface DOMContextInterface { export type DOMProps = DOMContextInterface; /* eslint-disable no-restricted-globals */ -const getDOM = (): DOMContextInterface => ({ +export const getDOM = (): DOMContextInterface => ({ window: canUseDOM ? window : undefined, document: canUseDOM ? document : undefined, }); diff --git a/website/components/mdx/Playground/PlaygroundToolbar/PlatformPicker/PlatformPicker.tsx b/website/components/mdx/Playground/PlaygroundToolbar/PlatformPicker/PlatformPicker.tsx index 0e0d2dff0fc..6811ed48084 100644 --- a/website/components/mdx/Playground/PlaygroundToolbar/PlatformPicker/PlatformPicker.tsx +++ b/website/components/mdx/Playground/PlaygroundToolbar/PlatformPicker/PlatformPicker.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Icon28ErrorCircleOutline } from '@vkontakte/icons'; -import { type PlatformType, SegmentedControl, Snackbar } from '@vkontakte/vkui'; +import { type PlatformType, SegmentedControl, snackbarManager } from '@vkontakte/vkui'; import { PlaygroundStoreContext, usePlaygroundStore } from '@/providers/playgroundStoreProvider'; import { DEFAULT_THEME_FOR_PLATFORM, DEFAULT_THEME_NAMES } from '../../vkuiThemes/constants'; import { getDefaultByThemesPresets, loadTheme } from '../../vkuiThemes/helpers'; @@ -10,7 +10,6 @@ export function PlatformPicker({ className }: { className?: string }) { const playgroundLoading = usePlaygroundStore((store) => store.playgroundLoading); const platform = usePlaygroundStore((store) => store.platform); const store = React.useContext(PlaygroundStoreContext); - const [snackbar, setSnackbar] = React.useState(null); const handlePlatformChange = async (newPlatform: PlatformType) => { if (store) { @@ -32,14 +31,10 @@ export function PlatformPicker({ className }: { className?: string }) { } catch (error) { // eslint-disable-next-line no-console console.warn(error); - setSnackbar( - setSnackbar(null)} - before={} - > - {`Не удалось загрузить токены для темы ${newThemeName}`} - , - ); + snackbarManager.open({ + before: , + children: `Не удалось загрузить токены для темы ${newThemeName}`, + }); } finally { updatePlaygroundLoading(false); } @@ -51,7 +46,6 @@ export function PlatformPicker({ className }: { className?: string }) { return ( <> - {snackbar} ) { const store = React.useContext(PlaygroundStoreContext); const { themeNames, isLoading, error } = useLoadThemeNames(); - const [snackbar, setSnackbar] = React.useState(null); const handleThemeSelect = async ( themeName: ThemeDefinitionProps['themeName'], @@ -71,14 +70,10 @@ function ThemesModalInner({ setOpen }: Pick) { } catch (error) { // eslint-disable-next-line no-console console.warn(error); - setSnackbar( - setSnackbar(null)} - before={} - > - {`Не удалось загрузить токены для темы ${themeName}`} - , - ); + snackbarManager.open({ + before: , + children: `Не удалось загрузить токены для темы ${themeName}`, + }); } finally { updatePlaygroundLoading(false); } @@ -87,7 +82,6 @@ function ThemesModalInner({ setOpen }: Pick) { return ( <> - {snackbar}
Ниже представлены все темы из{' '} diff --git a/website/components/mdx/Playground/scope.ts b/website/components/mdx/Playground/scope.ts index 25c3c4c2224..e658d9c007c 100644 --- a/website/components/mdx/Playground/scope.ts +++ b/website/components/mdx/Playground/scope.ts @@ -34,6 +34,7 @@ import { ContentBadge, ContentCard, Counter, + createSnackbarManager, CustomScrollView, CustomSelect, CustomSelectOption, @@ -120,6 +121,8 @@ import { Skeleton, Slider, Snackbar, + snackbarManager, + SnackbarManagerHolder, Spacing, Spinner, SplitCol, @@ -320,6 +323,9 @@ export const vkuiScope: Record = { useTodayDate, useTooltip, useReducedMotion, + SnackbarManagerHolder, + snackbarManager, + createSnackbarManager, }; export const scope: Record = { diff --git a/website/content/components/use-snackbar-manager.mdx b/website/content/components/use-snackbar-manager.mdx index 9bed1df94f5..a0b8f65ed70 100644 --- a/website/content/components/use-snackbar-manager.mdx +++ b/website/content/components/use-snackbar-manager.mdx @@ -1,12 +1,12 @@ --- -description: Хук для управления уведомлениями (снекбарами) в приложении. +description: Хук и императивный API для управления уведомлениями (снекбарами) в приложении. --- # useSnackbarManager -Хук для управления уведомлениями (снекбарами) в приложении. Позволяет показывать, обновлять и закрывать уведомления программно из любого места в коде. +Управление уведомлениями (снекбарами) в приложении: показ, обновление и закрытие программно из любого места в коде. Доступны два подхода: **хук** `useSnackbarManager` внутри компонента и **императивный API** через глобальный `snackbarManager` без хука. Связанные компоненты: @@ -16,19 +16,139 @@ description: Хук для управления уведомлениями (сн import { BlockWrapper } from "@/components/wrappers"; -## Быстрый старт +## Два способа использования -Хук возвращает два значения: API для управления снекбарами и компонент, который нужно добавить в разметку. +### 1. Императивный API (без хука) — из коробки + +Импортируйте `snackbarManager` и вызывайте методы в любом месте (компоненты, утилиты, обработчики). **Добавлять что-либо в разметку не нужно** — контейнер для снекбаров монтируется автоматически при первом вызове `open()` или `openCustom()`. + +```jsx +import { snackbarManager } from "@vkontakte/vkui"; + +// В компоненте, в колбэке, в сервисе — где угодно +snackbarManager.open({ + children: "Операция выполнена!", + action: "ОК", +}); + +snackbarManager.closeAll(); +``` + +Если нужна своя конфигурация (лимит, отступы, z-index), добавьте в корень приложения компонент `` с нужными пропсами — тогда авто-монтирование не создаст второй контейнер. + +**Когда использовать:** тосты из любого места (сервисы, колбэки, не только из React-компонентов), быстрый старт без разметки. + +> ⚠️ **Особый случай: приложение монтируется в `document.body`** +> +> По умолчанию при первом вызове `open()` контейнер для снекбаров автоматически добавляется в `document.body`. Если ваше приложение тоже монтируется непосредственно в `body` (например, `createRoot(document.body)`), это может привести к конфликтам — React-root приложения затрёт автоматически созданный контейнер. +> +> **Решение:** добавьте `` в дерево вашего приложения. Когда holder уже смонтирован, авто-монтирование не создаст второй контейнер. +> +> ```tsx +> import { createRoot } from "react-dom/client"; +> import { SnackbarManagerHolder } from "@vkontakte/vkui"; +> +> function App() { +> return ( +> <> +> {/* ваше приложение */} +> +> +> ); +> } +> +> createRoot(document.body).render(); +> ``` +> +> Также рекомендуется использовать отдельный контейнер вместо `body`: +> +> ```tsx +> createRoot(document.getElementById("root")!).render(); +> ``` + +#### Продвинутый вариант: полный контроль mount/unmount + +Если вам нужно полностью управлять жизненным циклом контейнера снекбаров (например, монтировать в конкретный элемент или корректно очищать ресурсы), используйте `setMountCallback` и `setUnmountCallback`: + +```tsx +import { createRoot } from "react-dom/client"; +import { + snackbarManager, + SnackbarManagerHolder, +} from "@vkontakte/vkui"; + +let holderRoot: ReturnType | null = null; +let holderContainer: HTMLElement | null = null; + +snackbarManager.setMountCallback(() => { + const target = document.getElementById("snackbar-portal"); + if (!target) { + return; + } + + holderContainer = document.createElement("div"); + target.appendChild(holderContainer); + holderRoot = createRoot(holderContainer); + holderRoot.render(); +}); + +snackbarManager.setUnmountCallback(() => { + if (holderRoot) { + holderRoot.unmount(); + holderRoot = null; + } + + if (holderContainer && holderContainer.parentNode) { + holderContainer.parentNode.removeChild(holderContainer); + holderContainer = null; + } +}); +``` + +Методы `setMountCallback` и `setUnmountCallback` доступны на любом экземпляре менеджера — как на глобальном `snackbarManager`, так и на созданном через `createSnackbarManager()`. В этом случае `` в дереве App не нужен — контейнер создаётся и удаляется через ваши колбэки. + +### 2. Хук useSnackbarManager + +Хук возвращает API и элемент для разметки. Удобно, когда снекбары нужны только в части приложения с собственной конфигурацией. + +```jsx +const [snackbarApi, snackbarHolder] = useSnackbarManager(); + +snackbarApi.open({ children: "Привет!" }); + +return <>{snackbarHolder}; +``` + +**Когда использовать:** снекбары только в части приложения, изолированная конфигурация для конкретного экрана или блока. + +## Быстрый старт (императивный API) + +Минимальный сценарий: импорт и вызов. Holder в DOM создаётся сам. + +```jsx +import { snackbarManager } from "@vkontakte/vkui"; + +function MyComponent() { + const handleSave = () => { + saveData(); + snackbarManager.open({ children: "Сохранено!" }); + }; + + return ; +} +``` + +## Быстрый старт (хук) + +Хук возвращает API и элемент, который нужно добавить в JSX. ```jsx const [snackbarApi, snackbarHolder] = useSnackbarManager(); -// Показать уведомление snackbarApi.open({ children: "Привет, мир!", }); -// Не забудьте добавить holder в JSX return ( <> {/* Ваш контент */} @@ -51,15 +171,34 @@ return ( | `offsetYEnd` | `string \| number` | Отступ контейнера со снекбарами от низа страницы. Полезно при использовании `FixedLayout`. | | `zIndex` | `string \| number` | Значение `z-index` для контейнера снекбаров. | -### Возвращаемое значение +### Возвращаемое значение (хук) Хук возвращает массив из двух элементов: 1. **`snackbarApi`** — объект с методами для управления снекбарами 2. **`snackbarHolder`** — React-элемент, который нужно добавить в JSX вашего компонента +### SnackbarManagerHolder и createSnackbarManager + +Для императивного API из пакета экспортируются: + +| Экспорт | Описание | +| -------- | -------- | +| `snackbarManager` | Глобальный экземпляр. Вызов `snackbarManager.open()` и т.д. из любого места. Контейнер монтируется автоматически при первом вызове. | +| `SnackbarManagerHolder` | Опциональный React-компонент. Добавьте в корень приложения, если нужны свои настройки (лимит, отступы, z-index). Если компонент уже смонтирован, авто-монтирование не создаёт второй контейнер. | +| `createSnackbarManager(options?)` | Создаёт отдельный экземпляр менеджера. Передайте его в `` и вызывайте `myManager.open()` там, где нужен этот экземпляр. | + +**Параметры SnackbarManagerHolder**: + +- Все свойства как для хука `useSnackbarManager()`; +- **`manager`**: + - Тип: `SnackbarApi.Api` + - Описание: Экземпляр менеджера. По умолчанию — глобальный `snackbarManager` + ### Методы +Все методы ниже доступны и у объекта из хука `useSnackbarManager()`, и у `snackbarManager` / экземпляра из `createSnackbarManager()`. + #### `open(config)` — открыть снекбар Показывает новое уведомление. Принимает те же свойства, что и компонент `Snackbar` (кроме `open` и `offsetY`). @@ -169,7 +308,52 @@ result.onClose().then(() => { ## Примеры использования -### Базовый пример +### Императивный API — без хука и без holder + +Импортируйте `snackbarManager` и вызывайте методы. Контейнер появится в DOM сам при первом показе снекбара. + + + +```jsx +const showSuccess = () => { + snackbarManager.open({ + children: "Операция выполнена успешно!", + action: "ОК", + }); +}; + +return ; +``` + + + +### Свой экземпляр менеджера (createSnackbarManager) + +Отдельный менеджер для своей зоны: создаёте экземпляр, рендерите под него holder и вызываете методы этого экземпляра. + + + +```jsx +const sidePanelSnackbar = createSnackbarManager({ limit: 2 }); + +const showInPanel = () => { + sidePanelSnackbar.open({ + children: "Снекбар только для этой панели", + action: "Закрыть", + }); +}; + +return ( + <> + + + +); +``` + + + +### Базовый пример (хук) {/* @example-description: Базовый пример `useSnackbarManager` для показа простого уведомления. */}