Skip to content

Commit bd7d71b

Browse files
feat(useSnackbarManager): imperative snackbar API
1 parent dd31fd3 commit bd7d71b

7 files changed

Lines changed: 266 additions & 92 deletions

File tree

packages/vkui/docs/icons-overview/IconsOverview.tsx

Lines changed: 25 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,8 @@ import {
1313
ConfigProvider,
1414
Flex,
1515
Mark,
16+
snackbarManager,
1617
Tooltip,
17-
useSnackbarManager,
1818
} from '../../src';
1919
import { Keys, pressedKey } from '../../src/lib/accessibility';
2020
import { OverviewLayout } from '../common/components/OverviewLayout';
@@ -28,6 +28,8 @@ const SIZES_OPTIONS: ChipOption[] = ICON_SIZES.map((size) => ({
2828
label: size,
2929
}));
3030

31+
snackbarManager.setLimit(1);
32+
3133
const filterConfig = (config: ConfigData[], query: string, sizes: string[]) => {
3234
if (!query && sizes.length === SIZES_OPTIONS.length) {
3335
return config;
@@ -52,9 +54,6 @@ const filterConfig = (config: ConfigData[], query: string, sizes: string[]) => {
5254
};
5355

5456
const IconsOverview = () => {
55-
const [snackbarApi, contextHolder] = useSnackbarManager({
56-
limit: 1,
57-
});
5857
const [selectedSizes, setSelectedSizes] = useState<ChipOption[]>(SIZES_OPTIONS);
5958
const rootRef = useRef<HTMLDivElement | null>(null);
6059

@@ -68,32 +67,29 @@ const IconsOverview = () => {
6867
[selectedSizes],
6968
);
7069

71-
const onIconClick = useCallback(
72-
(iconName: string) => {
73-
const iconCode = `<${iconName} />`;
70+
const onIconClick = useCallback((iconName: string) => {
71+
const iconCode = `<${iconName} />`;
7472

75-
navigator.clipboard
76-
.writeText(iconCode)
77-
.then(() => {
78-
snackbarApi.open({
79-
before: (
80-
<Avatar size={24} style={{ background: 'var(--vkui--color_background_accent)' }}>
81-
<Icon16Done fill="#fff" width={14} height={14} />
82-
</Avatar>
83-
),
84-
children: (
85-
<>
86-
<Mark>{iconCode}</Mark> скопировано!
87-
</>
88-
),
89-
style: { maxInlineSize: 'unset', inlineSize: 'fit-content' },
90-
placement: 'top-end',
91-
});
92-
})
93-
.catch(noop);
94-
},
95-
[snackbarApi],
96-
);
73+
navigator.clipboard
74+
.writeText(iconCode)
75+
.then(() => {
76+
snackbarManager.open({
77+
before: (
78+
<Avatar size={24} style={{ background: 'var(--vkui--color_background_accent)' }}>
79+
<Icon16Done fill="#fff" width={14} height={14} />
80+
</Avatar>
81+
),
82+
children: (
83+
<>
84+
<Mark>{iconCode}</Mark> скопировано!
85+
</>
86+
),
87+
style: { maxInlineSize: 'unset', inlineSize: 'fit-content' },
88+
placement: 'top-end',
89+
});
90+
})
91+
.catch(noop);
92+
}, []);
9793

9894
const onKeyDown = useCallback(
9995
(e: KeyboardEvent<HTMLElement>, iconName: string) => {
@@ -157,7 +153,6 @@ const IconsOverview = () => {
157153
</Flex>
158154
}
159155
/>
160-
{contextHolder}
161156
<ColorPickerControl containerRef={rootRef} />
162157
</div>
163158
);

packages/vkui/src/hooks/useSnackbarManager/components/SnackbarManagerHolder.tsx

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import * as React from 'react';
44
import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect';
5-
import { useIsDesktop } from '../helpers/useIsDesktop';
65
import {
76
getSnackbarManagerInternals,
87
snackbarManager,
@@ -28,12 +27,6 @@ export const SnackbarManagerHolder: React.FC<SnackbarManagerNS.HolderProps> = ({
2827
};
2928
}, [internals]);
3029

31-
const isDesktop = useIsDesktop();
32-
33-
useIsomorphicLayoutEffect(() => {
34-
internals.setIsDesktop(isDesktop);
35-
}, [internals, isDesktop]);
36-
3730
useIsomorphicLayoutEffect(() => {
3831
if (limit !== undefined) {
3932
manager.setLimit(limit);
@@ -64,7 +57,7 @@ export const SnackbarManagerHolder: React.FC<SnackbarManagerNS.HolderProps> = ({
6457
}
6558
}, [manager, zIndex]);
6659

67-
const config = React.useSyncExternalStore<SnackbarManagerConfig>(
60+
const configStore = React.useSyncExternalStore<SnackbarManagerConfig>(
6861
internals.subscribeConfig,
6962
internals.getConfig,
7063
internals.getConfig,
@@ -73,10 +66,10 @@ export const SnackbarManagerHolder: React.FC<SnackbarManagerNS.HolderProps> = ({
7366
return (
7467
<SnackbarHolder
7568
store={internals.store}
76-
limit={config.limit}
77-
offsetYStart={config.offsetYStart}
78-
offsetYEnd={config.offsetYEnd}
79-
zIndex={config.zIndex}
69+
limit={configStore.limit}
70+
offsetYStart={configStore.offsetYStart}
71+
offsetYEnd={configStore.offsetYEnd}
72+
zIndex={configStore.zIndex}
8073
/>
8174
);
8275
};

packages/vkui/src/hooks/useSnackbarManager/helpers/useIsDesktop.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,27 @@
11
import * as React from 'react';
2+
import { MEDIA_QUERIES } from '../../../lib/adaptivity';
23
import { useDOM } from '../../../lib/dom';
34
import { matchMediaListAddListener, matchMediaListRemoveListener } from '../../../lib/matchMedia';
45
import { useMediaQueries } from '../../useMediaQueries';
56

7+
/**
8+
* Определяет desktop-режим по окну без хуков.
9+
* Desktop: (smallTabletPlus and pointer: fine) or (smallTabletPlus and mediumHeight)
10+
* Совпадает с логикой useIsDesktop().
11+
*/
12+
export function getIsDesktop(window: Window | null | undefined): boolean {
13+
if (!window) {
14+
return false;
15+
}
16+
// eslint-disable-next-line no-restricted-properties
17+
const smallTabletPlus = window.matchMedia(MEDIA_QUERIES.SMALL_TABLET_PLUS).matches;
18+
// eslint-disable-next-line no-restricted-properties
19+
const mediumHeight = window.matchMedia(MEDIA_QUERIES.MEDIUM_HEIGHT).matches;
20+
// eslint-disable-next-line no-restricted-properties
21+
const pointerFine = window.matchMedia('(pointer: fine)').matches;
22+
return smallTabletPlus && (pointerFine || mediumHeight);
23+
}
24+
625
/**
726
* Хук для определения desktop режима.
827
* Desktop: (smallTabletPlus and pointer: fine) or (smallTabletPlus and mediumHeight)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
import { act, render, screen, waitFor } from '@testing-library/react';
2+
import { waitCSSKeyframesAnimation, withFakeTimers } from '../../testing/utils';
3+
import { SnackbarManagerHolder } from './components/SnackbarManagerHolder';
4+
import {
5+
createSnackbarManager,
6+
getSnackbarManagerInternals,
7+
snackbarManager,
8+
} from './snackbarManager';
9+
10+
const AUTO_MOUNT_HOLDER_SELECTOR = '[data-vkui-snackbar-manager-holder]';
11+
12+
describe('snackbarManager (imperative API)', () => {
13+
afterEach(() => {
14+
act(() => {
15+
snackbarManager.closeAll();
16+
});
17+
document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)?.remove();
18+
});
19+
20+
it('auto-mounts SnackbarManagerHolder in document.body on first open()', async () => {
21+
expect(document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)).not.toBeInTheDocument();
22+
23+
act(() => {
24+
snackbarManager.open({ children: 'Auto-mounted snackbar' });
25+
});
26+
27+
await waitFor(() => {
28+
expect(screen.getByText('Auto-mounted snackbar')).toBeInTheDocument();
29+
});
30+
31+
expect(document.body.querySelector(AUTO_MOUNT_HOLDER_SELECTOR)).toBeInTheDocument();
32+
});
33+
34+
it('opens snackbar when SnackbarManagerHolder is mounted', async () => {
35+
render(<SnackbarManagerHolder />);
36+
37+
act(() => {
38+
snackbarManager.open({ children: 'Global snackbar' });
39+
});
40+
41+
expect(screen.getByText('Global snackbar')).toBeInTheDocument();
42+
});
43+
44+
it(
45+
'closes snackbar by id',
46+
withFakeTimers(async () => {
47+
render(<SnackbarManagerHolder />);
48+
49+
let id: string | undefined;
50+
act(() => {
51+
const result = snackbarManager.open({
52+
'children': 'To close',
53+
'data-testid': 'snackbar-to-close',
54+
});
55+
id = result.id;
56+
});
57+
58+
const snackbarEl = screen.getByTestId('snackbar-to-close');
59+
expect(snackbarEl).toBeInTheDocument();
60+
61+
act(() => {
62+
snackbarManager.close(id!);
63+
});
64+
65+
const alert = snackbarEl.querySelector('[role="alert"]') ?? snackbarEl;
66+
await waitCSSKeyframesAnimation(alert as HTMLElement, {
67+
runOnlyPendingTimers: true,
68+
});
69+
expect(screen.queryByTestId('snackbar-to-close')).not.toBeInTheDocument();
70+
}),
71+
);
72+
73+
it(
74+
'closeAll closes all snackbars',
75+
withFakeTimers(async () => {
76+
render(<SnackbarManagerHolder />);
77+
78+
act(() => {
79+
snackbarManager.open({ children: 'First' });
80+
snackbarManager.open({ children: 'Second' });
81+
});
82+
83+
expect(screen.getByText('First')).toBeInTheDocument();
84+
expect(screen.getByText('Second')).toBeInTheDocument();
85+
86+
act(() => {
87+
snackbarManager.closeAll();
88+
});
89+
90+
const alerts = screen.getAllByRole('alert');
91+
await Promise.all(
92+
alerts.map((alert) => waitCSSKeyframesAnimation(alert, { runOnlyPendingTimers: true })),
93+
);
94+
95+
expect(screen.queryByText('First')).not.toBeInTheDocument();
96+
expect(screen.queryByText('Second')).not.toBeInTheDocument();
97+
}),
98+
);
99+
100+
it('update changes snackbar content', async () => {
101+
render(<SnackbarManagerHolder />);
102+
103+
let id: string | undefined;
104+
act(() => {
105+
const result = snackbarManager.open({ children: 'Initial text' });
106+
id = result.id;
107+
});
108+
109+
expect(screen.getByText('Initial text')).toBeInTheDocument();
110+
111+
act(() => {
112+
snackbarManager.update(id!, { children: 'Updated text' });
113+
});
114+
115+
expect(screen.queryByText('Initial text')).not.toBeInTheDocument();
116+
expect(screen.getByText('Updated text')).toBeInTheDocument();
117+
});
118+
119+
it('applies SnackbarManagerHolder props and shows snackbar', async () => {
120+
render(<SnackbarManagerHolder limit={2} zIndex={8888} />);
121+
122+
act(() => {
123+
snackbarManager.open({ children: 'With zIndex' });
124+
});
125+
126+
expect(screen.getByText('With zIndex')).toBeInTheDocument();
127+
});
128+
});
129+
130+
describe('createSnackbarManager', () => {
131+
it('creates independent manager instance', async () => {
132+
const customManager = createSnackbarManager({ limit: 1 });
133+
134+
render(<SnackbarManagerHolder manager={customManager} />);
135+
136+
act(() => {
137+
customManager.open({ children: 'From custom manager' });
138+
});
139+
140+
expect(screen.getByText('From custom manager')).toBeInTheDocument();
141+
142+
act(() => {
143+
customManager.closeAll();
144+
});
145+
});
146+
147+
it(
148+
'custom manager and global snackbarManager are independent',
149+
withFakeTimers(async () => {
150+
const customManager = createSnackbarManager();
151+
152+
render(
153+
<>
154+
<SnackbarManagerHolder />
155+
<SnackbarManagerHolder manager={customManager} />
156+
</>,
157+
);
158+
159+
act(() => {
160+
snackbarManager.open({ children: 'Global' });
161+
customManager.open({ children: 'Custom' });
162+
});
163+
164+
expect(screen.getByText('Global')).toBeInTheDocument();
165+
expect(screen.getByText('Custom')).toBeInTheDocument();
166+
167+
act(() => {
168+
customManager.closeAll();
169+
});
170+
171+
const customAlerts = screen
172+
.getAllByRole('alert')
173+
.filter((el) => el.textContent?.includes('Custom'));
174+
await Promise.all(
175+
customAlerts.map((alert) =>
176+
waitCSSKeyframesAnimation(alert, { runOnlyPendingTimers: true }),
177+
),
178+
);
179+
180+
expect(screen.getByText('Global')).toBeInTheDocument();
181+
expect(screen.queryByText('Custom')).not.toBeInTheDocument();
182+
183+
act(() => {
184+
snackbarManager.closeAll();
185+
});
186+
}),
187+
);
188+
});
189+
190+
describe('getSnackbarManagerInternals', () => {
191+
it('throws when passed non-manager object', () => {
192+
expect(() => getSnackbarManagerInternals({} as any)).toThrow('VKUI');
193+
});
194+
});

0 commit comments

Comments
 (0)