Skip to content

Commit dd31fd3

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

12 files changed

Lines changed: 560 additions & 152 deletions

File tree

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
'use client';
2+
3+
import * as React from 'react';
4+
import { useIsomorphicLayoutEffect } from '../../../lib/useIsomorphicLayoutEffect';
5+
import { useIsDesktop } from '../helpers/useIsDesktop';
6+
import {
7+
getSnackbarManagerInternals,
8+
snackbarManager,
9+
type SnackbarManagerConfig,
10+
} from '../snackbarManager';
11+
import type { SnackbarManagerNS } from '../types';
12+
import { SnackbarHolder } from './SnackbarHolder';
13+
14+
export const SnackbarManagerHolder: React.FC<SnackbarManagerNS.HolderProps> = ({
15+
manager = snackbarManager,
16+
limit,
17+
queueStrategy,
18+
offsetYStart,
19+
offsetYEnd,
20+
zIndex,
21+
}) => {
22+
const internals = getSnackbarManagerInternals(manager);
23+
24+
React.useEffect(() => {
25+
internals.registerHolder();
26+
return () => {
27+
internals.unregisterHolder();
28+
};
29+
}, [internals]);
30+
31+
const isDesktop = useIsDesktop();
32+
33+
useIsomorphicLayoutEffect(() => {
34+
internals.setIsDesktop(isDesktop);
35+
}, [internals, isDesktop]);
36+
37+
useIsomorphicLayoutEffect(() => {
38+
if (limit !== undefined) {
39+
manager.setLimit(limit);
40+
}
41+
}, [manager, limit]);
42+
43+
useIsomorphicLayoutEffect(() => {
44+
if (queueStrategy !== undefined) {
45+
manager.setQueueStrategy(queueStrategy);
46+
}
47+
}, [manager, queueStrategy]);
48+
49+
useIsomorphicLayoutEffect(() => {
50+
if (offsetYStart !== undefined) {
51+
manager.setOffsetYStart(offsetYStart);
52+
}
53+
}, [manager, offsetYStart]);
54+
55+
useIsomorphicLayoutEffect(() => {
56+
if (offsetYEnd !== undefined) {
57+
manager.setOffsetYEnd(offsetYEnd);
58+
}
59+
}, [manager, offsetYEnd]);
60+
61+
useIsomorphicLayoutEffect(() => {
62+
if (zIndex !== undefined) {
63+
manager.setZIndex(zIndex);
64+
}
65+
}, [manager, zIndex]);
66+
67+
const config = React.useSyncExternalStore<SnackbarManagerConfig>(
68+
internals.subscribeConfig,
69+
internals.getConfig,
70+
internals.getConfig,
71+
);
72+
73+
return (
74+
<SnackbarHolder
75+
store={internals.store}
76+
limit={config.limit}
77+
offsetYStart={config.offsetYStart}
78+
offsetYEnd={config.offsetYEnd}
79+
zIndex={config.zIndex}
80+
/>
81+
);
82+
};
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export const DEFAULT_LIMIT = 4;
2+
export const DEFAULT_QUEUE_STRATEGY = 'shift';
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
import type * as React from 'react';
2+
import { randomUUID } from '../../../lib/randomUUID';
3+
import { SnackbarWrapper } from '../components/SnackbarWrapper';
4+
import {
5+
type CommonOnOpenPayload,
6+
type CustomSnackbar,
7+
type SnackbarApi,
8+
type SnackbarPlacement,
9+
} from '../types';
10+
import type { SnackbarStore } from './createSnackbarStore';
11+
12+
const resolveMobilePlacement = (
13+
placement: SnackbarPlacement,
14+
): Extract<SnackbarPlacement, 'top' | 'bottom-start'> => {
15+
if (placement.startsWith('top')) {
16+
return 'top';
17+
}
18+
return 'bottom-start';
19+
};
20+
21+
const resolveCustomPayload = <AdditionalProps extends object>(
22+
props:
23+
| CustomSnackbar.Payload<AdditionalProps>
24+
| React.ComponentType<CustomSnackbar.Props<AdditionalProps>>,
25+
): CustomSnackbar.Payload<AdditionalProps> => {
26+
if ('component' in props) {
27+
return props;
28+
}
29+
return {
30+
component: props,
31+
};
32+
};
33+
34+
export type SnackbarActionsConfig = {
35+
getLimit: () => number;
36+
getQueueStrategy: () => SnackbarApi.QueueStrategy;
37+
getIsDesktop: () => boolean;
38+
};
39+
40+
export type SnackbarActions = Pick<
41+
SnackbarApi.Api,
42+
'open' | 'openCustom' | 'update' | 'close' | 'closeAll'
43+
>;
44+
45+
export const createSnackbarActions = (
46+
store: SnackbarStore,
47+
config: SnackbarActionsConfig,
48+
): SnackbarActions => {
49+
const update: SnackbarApi.Api['update'] = (id, updateConfig) => {
50+
store.updateSnackbar(id, updateConfig);
51+
};
52+
53+
const close: SnackbarApi.Api['close'] = (id) => {
54+
if (store.showedSnackbars.has(id)) {
55+
store.closeSnackbar(id);
56+
} else {
57+
store.removeSnackbar(id);
58+
}
59+
};
60+
61+
const onOpenSnackbarImpl = (item: CommonOnOpenPayload): SnackbarApi.OpenReturn => {
62+
const placement: SnackbarPlacement = item.snackbarProps?.placement || 'bottom-start';
63+
const resolvedPlacement = config.getIsDesktop() ? placement : resolveMobilePlacement(placement);
64+
65+
const limit = config.getLimit();
66+
const placementSnackbars = store.getSnackbarsByPlacement(resolvedPlacement, limit);
67+
68+
const withOverflow =
69+
config.getQueueStrategy() === 'shift' && placementSnackbars.length >= limit;
70+
71+
let resolvePromise: () => void;
72+
const promise = new Promise<void>((resolve) => {
73+
resolvePromise = resolve;
74+
});
75+
76+
const id = item.id;
77+
78+
if (withOverflow) {
79+
store.closeOverflowedSnackbars(placementSnackbars);
80+
}
81+
82+
store.addSnackbar({
83+
...item,
84+
id,
85+
snackbarProps: {
86+
...item.snackbarProps,
87+
id,
88+
placement: resolvedPlacement,
89+
onClosed: () => {
90+
resolvePromise!();
91+
item.snackbarProps?.onClosed?.();
92+
},
93+
},
94+
});
95+
96+
return {
97+
id,
98+
close: () => close(id),
99+
update: (newProps) => update(id, newProps),
100+
onClose: <R>(resolve?: () => R) => {
101+
return promise.then(resolve);
102+
},
103+
};
104+
};
105+
106+
const openCustom: SnackbarApi.Api['openCustom'] = (openConfig) => {
107+
const resolvedProps = resolveCustomPayload(openConfig);
108+
const id = resolvedProps.id || randomUUID();
109+
110+
return onOpenSnackbarImpl({
111+
id,
112+
component: resolvedProps.component,
113+
snackbarProps: resolvedProps.baseProps,
114+
additionalProps: resolvedProps.additionalProps,
115+
close: () => close(id),
116+
update: (newProps) => update(id, newProps),
117+
});
118+
};
119+
120+
const open: SnackbarApi.Api['open'] = (openConfig) => {
121+
return openCustom({
122+
id: openConfig.id,
123+
component: SnackbarWrapper,
124+
baseProps: openConfig,
125+
});
126+
};
127+
128+
const closeAll: SnackbarApi.Api['closeAll'] = () => {
129+
store.closeAll(store.showedSnackbars);
130+
};
131+
132+
return { open, openCustom, update, close, closeAll };
133+
};
Lines changed: 10 additions & 137 deletions
Original file line numberDiff line numberDiff line change
@@ -1,36 +1,7 @@
11
import * as React from 'react';
2-
import { randomUUID } from '../../../lib/randomUUID';
3-
import { SnackbarWrapper } from '../components/SnackbarWrapper';
4-
import {
5-
type CommonOnOpenPayload,
6-
type CustomSnackbar,
7-
type SnackbarApi,
8-
type SnackbarPlacement,
9-
} from '../types';
2+
import { createSnackbarActions, type SnackbarActions } from './createSnackbarActions';
103
import type { SnackbarStore } from './createSnackbarStore';
114

12-
const resolveMobilePlacement = (
13-
placement: SnackbarPlacement,
14-
): Extract<SnackbarPlacement, 'top' | 'bottom-start'> => {
15-
if (placement.startsWith('top')) {
16-
return 'top';
17-
}
18-
return 'bottom-start';
19-
};
20-
21-
const resolveProps = <AdditionalProps extends object>(
22-
props:
23-
| CustomSnackbar.Payload<AdditionalProps>
24-
| React.ComponentType<CustomSnackbar.Props<AdditionalProps>>,
25-
): CustomSnackbar.Payload<AdditionalProps> => {
26-
if ('component' in props) {
27-
return props;
28-
}
29-
return {
30-
component: props,
31-
};
32-
};
33-
345
export type UseSnackbarActionsWithStoreProps = {
356
store: SnackbarStore;
367
limit: number;
@@ -43,7 +14,7 @@ export const useSnackbarActionsWithStore = ({
4314
limit,
4415
queueStrategy,
4516
isDesktop,
46-
}: UseSnackbarActionsWithStoreProps) => {
17+
}: UseSnackbarActionsWithStoreProps): SnackbarActions => {
4718
const limitRef = React.useRef(limit);
4819
const queueStrategyRef = React.useRef(queueStrategy);
4920
const isDesktopRef = React.useRef(isDesktop);
@@ -60,111 +31,13 @@ export const useSnackbarActionsWithStore = ({
6031
isDesktopRef.current = isDesktop;
6132
}, [isDesktop]);
6233

63-
const update: SnackbarApi.Api['update'] = React.useCallback(
64-
(id, config) => {
65-
store.updateSnackbar(id, config);
66-
},
67-
[store],
68-
);
69-
70-
const close: SnackbarApi.Api['close'] = React.useCallback(
71-
(id) => {
72-
if (store.showedSnackbars.has(id)) {
73-
store.closeSnackbar(id);
74-
} else {
75-
store.removeSnackbar(id);
76-
}
77-
},
78-
[store],
79-
);
80-
81-
const onOpenSnackbarImpl = React.useCallback(
82-
(item: CommonOnOpenPayload): SnackbarApi.OpenReturn => {
83-
const placement: SnackbarPlacement = item.snackbarProps?.placement || 'bottom-start';
84-
const resolvedPlacement = isDesktopRef.current
85-
? placement
86-
: resolveMobilePlacement(placement);
87-
88-
const placementSnackbars = store.getSnackbarsByPlacement(resolvedPlacement, limitRef.current);
89-
90-
const withOverflow =
91-
queueStrategyRef.current === 'shift' && placementSnackbars.length >= limitRef.current;
92-
93-
let resolvePromise: () => void;
94-
const promise = new Promise<void>((resolve) => {
95-
resolvePromise = resolve;
96-
});
97-
98-
const id = item.id;
99-
100-
if (withOverflow) {
101-
store.closeOverflowedSnackbars(placementSnackbars);
102-
}
103-
104-
store.addSnackbar({
105-
...item,
106-
id,
107-
snackbarProps: {
108-
...item.snackbarProps,
109-
id,
110-
placement: resolvedPlacement,
111-
onClosed: () => {
112-
resolvePromise!();
113-
item.snackbarProps?.onClosed?.();
114-
},
115-
},
116-
});
117-
118-
return {
119-
id,
120-
close: () => close(id),
121-
update: (newProps) => update(id, newProps),
122-
onClose: <R>(resolve?: () => R) => {
123-
return promise.then(resolve);
124-
},
125-
};
126-
},
127-
[close, store, update],
128-
);
129-
130-
const openCustom: SnackbarApi.Api['openCustom'] = React.useCallback(
131-
(config) => {
132-
const resolvedProps = resolveProps(config);
133-
134-
const id = resolvedProps.id || randomUUID();
135-
136-
return onOpenSnackbarImpl({
137-
id,
138-
component: resolvedProps.component,
139-
snackbarProps: resolvedProps.baseProps,
140-
additionalProps: resolvedProps.additionalProps,
141-
close: () => close(id),
142-
update: (newProps) => update(id, newProps),
143-
});
144-
},
145-
[close, onOpenSnackbarImpl, update],
146-
);
147-
148-
const open: SnackbarApi.Api['open'] = React.useCallback(
149-
(config) => {
150-
return openCustom({
151-
id: config.id,
152-
component: SnackbarWrapper,
153-
baseProps: config,
154-
});
155-
},
156-
[openCustom],
157-
);
158-
159-
const closeAllSnackbars: SnackbarApi.Api['closeAll'] = React.useCallback(() => {
160-
store.closeAll(store.showedSnackbars);
34+
return React.useMemo<SnackbarActions>(() => {
35+
/* eslint-disable react-hooks/refs -- refs читаются в колбэках API (open/close), не при рендере */
36+
return createSnackbarActions(store, {
37+
getLimit: () => limitRef.current,
38+
getQueueStrategy: () => queueStrategyRef.current,
39+
getIsDesktop: () => isDesktopRef.current,
40+
});
41+
/* eslint-enable react-hooks/refs */
16142
}, [store]);
162-
163-
return {
164-
open,
165-
openCustom,
166-
update,
167-
close,
168-
closeAll: closeAllSnackbars,
169-
};
17043
};

0 commit comments

Comments
 (0)