Skip to content

Commit 0fc9f92

Browse files
committed
Add useModalAt for parents access, and close...() functions from useModals now resolve their promises.
1 parent 53c7130 commit 0fc9f92

6 files changed

Lines changed: 153 additions & 105 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -85,11 +85,12 @@ import Modal from "./Modal";
8585
function Page() {
8686
const modals = useModals();
8787

88-
const handleClick = () => {
89-
modals.open(Modal, {
88+
async function handleClick() {
89+
const result = await modals.open(Modal, {
9090
title: "Alert",
9191
message: "This is an alert",
9292
});
93+
console.log(result);
9394
};
9495

9596
return <button onClick={handleClick}>Open Modal</button>;

src/context.tsx

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
import { createContext, Fragment, ReactNode, useContext } from "react";
2-
import { ModalItemProvider } from "./item-context.js";
2+
import { ModalItemProvider, toModalProps } from "./item-context.js";
33
import {
44
InternalModalInstance,
55
InternalModalInstanceItem,
66
ModalManager,
7-
ModalProps,
87
ModalProviderProps,
98
} from "./types.js";
109
import { useModalManager } from "./use-modal-manager.js";
@@ -35,34 +34,40 @@ function Modals({
3534
const modalManager = useModals();
3635
const { stack, isLoading, createOpen } = modalManager;
3736

38-
const renderModal = (internalModal: InternalModalInstance, isNested: boolean = false): ReactNode => {
37+
const renderModal = (
38+
internalModal: InternalModalInstance,
39+
ancestors: InternalModalInstanceItem[] = [],
40+
isNested: boolean = false
41+
): ReactNode => {
3942
const open = createOpen(internalModal.id);
4043

41-
const nestedElement = internalModal.nested
42-
? renderModal(internalModal.nested, true)
43-
: null;
44-
45-
const internalModalItem: InternalModalInstanceItem = {
44+
// Ancestor view exposed to descendants: nested is null since descendants
45+
// shouldn't introspect their own subtree via useModalAt(...).nested
46+
const ancestorView: InternalModalInstanceItem = {
4647
...internalModal,
47-
nested: nestedElement,
48+
nested: null,
4849
open,
4950
isNested,
51+
ancestors,
5052
};
5153

52-
const modalProps: ModalProps = {
53-
id: internalModalItem.id,
54-
isOpen: internalModalItem.isOpen,
55-
isNested: internalModalItem.isNested,
56-
close: internalModalItem.close,
57-
index: internalModalItem.index,
54+
const nestedElement = internalModal.nested
55+
? renderModal(
56+
internalModal.nested,
57+
[...ancestors, ancestorView],
58+
true
59+
)
60+
: null;
61+
62+
const internalModalItem: InternalModalInstanceItem = {
63+
...ancestorView,
5864
nested: nestedElement,
59-
open,
6065
};
6166

6267
return (
6368
<ModalItemProvider modal={internalModalItem}>
6469
{modal ? (
65-
modal(modalProps, modalManager)
70+
modal(toModalProps(internalModalItem), modalManager)
6671
) : (
6772
<internalModalItem.component
6873
{...internalModalItem.props}

src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
export { ModalProvider, useModals } from "./context.js";
2-
export { useBeforeClose, useModal } from "./item-context.js";
3-
export type { ModalProps } from "./types.js";
2+
export { useBeforeClose, useModal, useModalAt } from "./item-context.js";
3+
export type { ModalProps, ModalLevel } from "./types.js";
44
export { useModalManager } from "./use-modal-manager.js";

src/item-context.tsx

Lines changed: 68 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,85 @@
11
import { createContext, useContext, useEffect } from "react";
2-
import { InternalModalInstanceItem, ModalProps } from "./types.js";
2+
import {
3+
InternalModalInstanceItem,
4+
ModalLevel,
5+
ModalProps,
6+
} from "./types.js";
37

48
const ModalItemContext = createContext<InternalModalInstanceItem | null>(null);
59

6-
export function useModal<R = unknown>(): ModalProps<R> {
7-
const modal = useContext(ModalItemContext);
8-
9-
if (!modal) {
10-
throw new Error("useModal must be called inside a modal component");
11-
}
12-
10+
export function toModalProps<R>(modal: InternalModalInstanceItem<unknown, R>): ModalProps<R> {
1311
return {
1412
id: modal.id,
1513
index: modal.index,
1614
isOpen: modal.isOpen,
1715
isNested: modal.isNested,
18-
close: modal.close as (value?: R) => void,
16+
close: modal.close,
1917
open: modal.open,
2018
nested: modal.nested,
2119
};
2220
}
2321

22+
function resolveAncestor(
23+
modal: InternalModalInstanceItem,
24+
level: ModalLevel
25+
): InternalModalInstanceItem {
26+
const chain = modal.ancestors;
27+
28+
if (level === "root") {
29+
return chain[0] ?? modal;
30+
}
31+
32+
if (typeof level === "number") {
33+
if (level === 0) return modal;
34+
if (level > 0) {
35+
throw new Error(
36+
`useModalAt: positive level not supported. Use a negative number to go up, or 'root' for top.`
37+
);
38+
}
39+
const idx = chain.length + level;
40+
if (idx < 0) {
41+
throw new Error(
42+
`useModalAt: no ancestor at level ${level} (current depth: ${chain.length}).`
43+
);
44+
}
45+
return chain[idx];
46+
}
47+
48+
if (level && typeof level === "object" && "id" in level) {
49+
if (modal.id === level.id) return modal;
50+
const found = chain.find((a) => a.id === level.id);
51+
if (!found) {
52+
throw new Error(
53+
`useModalAt: no ancestor with id "${level.id}" in current chain.`
54+
);
55+
}
56+
return found;
57+
}
58+
59+
throw new Error(`useModalAt: invalid level ${JSON.stringify(level)}.`);
60+
}
61+
62+
export function useModal<R = unknown>(): ModalProps<R> {
63+
const modal = useContext(ModalItemContext);
64+
65+
if (!modal) {
66+
throw new Error("useModal must be called inside a modal component");
67+
}
68+
69+
return toModalProps<R>(modal);
70+
}
71+
72+
export function useModalAt<R = unknown>(level: ModalLevel): ModalProps<R> {
73+
const modal = useContext(ModalItemContext);
74+
75+
if (!modal) {
76+
throw new Error("useModalAt must be called inside a modal component");
77+
}
78+
79+
const target = resolveAncestor(modal, level);
80+
return toModalProps<R>(target);
81+
}
82+
2483
export function useBeforeClose<R = unknown>(callback: (value?: R) => boolean) {
2584
const modal = useContext(ModalItemContext);
2685

src/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ export interface InternalModalInstanceItem<T = any, ReturnValue = any>
5252
component: ModalComponent<T, ReturnValue>;
5353
props?: Omit<T, keyof ModalProps<ReturnValue>>;
5454
onBeforeClose: (callback: (value?: ReturnValue) => boolean) => void;
55+
ancestors: InternalModalInstanceItem[];
5556
}
5657

58+
export type ModalLevel = number | "root" | { id: string };
59+
5760
export interface ModalManager {
5861
open: {
5962
<T extends Record<string, never>, R = any>(

src/use-modal-manager.ts

Lines changed: 55 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export function useModalManager(): ModalManager {
1717
const beforeCloseCallbacks = useRef<
1818
Map<string, (value?: unknown) => boolean>
1919
>(new Map());
20+
const stackRef = useRef(stack);
21+
stackRef.current = stack;
2022

2123
const generateId = useCallback(() => {
2224
return `modal-${nextId.current++}`;
@@ -100,31 +102,47 @@ export function useModalManager(): ModalManager {
100102
return callback ? callback(value) : true;
101103
}, []);
102104

103-
const closeById = useCallback(
104-
(id: string, options?: CloseOptions): boolean => {
105-
let wasClosed = false;
106-
107-
setStack((prev) => {
108-
const modal = prev.find((m) => m.id === id);
109-
if (!modal) {
110-
console.error(`Modal with ID "${id}" not found, and cant be closed`);
111-
return prev;
112-
}
113-
114-
if (!options?.force && !canClose(id)) {
115-
return prev;
105+
const findModalById = useCallback(
106+
(
107+
modals: InternalModalInstance[],
108+
id: string
109+
): InternalModalInstance | null => {
110+
for (const m of modals) {
111+
if (m.id === id) return m;
112+
if (m.nested) {
113+
const found = findModalById([m.nested], id);
114+
if (found) return found;
116115
}
116+
}
117+
return null;
118+
},
119+
[]
120+
);
117121

118-
wasClosed = true;
119-
beforeCloseCallbacks.current.delete(id);
120-
const newStack = prev.filter((m) => m.id !== id);
121-
setAction("pop");
122-
return updateModalStack(newStack);
123-
});
122+
// Close deepest nested first so promises resolve bottom-up
123+
const closeChain = useCallback(
124+
(m: InternalModalInstance, options?: CloseOptions): boolean => {
125+
let didClose = false;
126+
if (m.nested) didClose = closeChain(m.nested, options) || didClose;
127+
if (options?.force || canClose(m.id)) {
128+
m.close(undefined, options);
129+
didClose = true;
130+
}
131+
return didClose;
132+
},
133+
[canClose]
134+
);
124135

125-
return wasClosed;
136+
const closeById = useCallback(
137+
(id: string, options?: CloseOptions): boolean => {
138+
const modal = findModalById(stackRef.current, id);
139+
if (!modal) {
140+
console.error(`Modal with ID "${id}" not found, and cant be closed`);
141+
return false;
142+
}
143+
return closeChain(modal, options);
126144
},
127-
[updateModalStack, canClose]
145+
[findModalById, closeChain]
128146
);
129147

130148
const closeCurrent = useCallback(
@@ -226,66 +244,28 @@ export function useModalManager(): ModalManager {
226244
`amount must be a number greater than 0. Received ${n}`
227245
);
228246
}
247+
const current = stackRef.current;
229248
let didClose = false;
230-
231-
setStack((prev) => {
232-
let closedCount = 0;
233-
let newStack = [...prev];
234-
235-
for (let i = prev.length - 1; i >= 0 && closedCount < n; i--) {
236-
const modal = prev[i];
237-
if (options?.force || canClose(modal.id)) {
238-
beforeCloseCallbacks.current.delete(modal.id);
239-
newStack = newStack.filter((m) => m.id !== modal.id);
240-
closedCount += 1;
241-
}
242-
}
243-
244-
if (closedCount > 0) {
245-
didClose = true;
246-
setAction("pop");
247-
}
248-
249-
return updateModalStack(newStack);
250-
});
251-
249+
for (let i = current.length - 1, count = 0; i >= 0 && count < n; i--, count++) {
250+
if (closeChain(current[i], options)) didClose = true;
251+
}
252252
return didClose;
253253
},
254-
[updateModalStack, canClose]
254+
[closeChain]
255255
);
256256

257-
const closeAll = useCallback((options?: CloseOptions): boolean => {
258-
let wasAnyClosed = false;
259-
260-
setStack((prev) => {
261-
if (prev.length === 0) return prev;
262-
263-
if (options?.force) {
264-
wasAnyClosed = true;
265-
setAction("pop");
266-
beforeCloseCallbacks.current.clear();
267-
return [];
268-
}
269-
270-
const remainingModals: InternalModalInstance[] = [];
271-
for (const modal of prev) {
272-
if (canClose(modal.id)) {
273-
wasAnyClosed = true;
274-
beforeCloseCallbacks.current.delete(modal.id);
275-
} else {
276-
remainingModals.push(modal);
277-
}
278-
}
279-
280-
if (wasAnyClosed) {
281-
setAction("pop");
257+
const closeAll = useCallback(
258+
(options?: CloseOptions): boolean => {
259+
const current = stackRef.current;
260+
if (current.length === 0) return false;
261+
let didClose = false;
262+
for (const m of current) {
263+
if (closeChain(m, options)) didClose = true;
282264
}
283-
284-
return updateModalStack(remainingModals);
285-
});
286-
287-
return wasAnyClosed;
288-
}, [canClose, updateModalStack]);
265+
return didClose;
266+
},
267+
[closeChain]
268+
);
289269

290270
const openNested = useCallback(
291271
<T = unknown, R = unknown>(

0 commit comments

Comments
 (0)