Skip to content

Commit 58de6eb

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

7 files changed

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

0 commit comments

Comments
 (0)