@ivex0002/stack-modal 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,595 @@
1
+ # @ivex0002/stack-modal
2
+
3
+ A flexible and customizable modal stack management library for React applications. Modal Stack allows you to create layered modals with smooth animations and various preset styles.
4
+
5
+ ## Preview
6
+
7
+ ### default preset
8
+
9
+ ![Honeycam 2025-12-28 13-23-52](https://github.com/user-attachments/assets/e1cf2c64-10c3-4d50-bebf-771bf6154ad1)
10
+
11
+ ### custom layout style (tailwind, framer-motion)
12
+
13
+ ![Honeycam 2025-12-28 13-24-38](https://github.com/user-attachments/assets/b7573c8b-2ea5-41a4-8ad5-4bf2dd697ca5)
14
+
15
+ ### test project link
16
+
17
+ https://github.com/Ivex0002/stack-modal-test-project
18
+
19
+ ## Features
20
+
21
+ - 🎨 **Multiple Presets**: Default, Minimal, and Drawer layouts
22
+ - 📚 **Modal Stacking**: Support for multiple modals with depth management
23
+ - âŒ¨ī¸ **Keyboard Support**: ESC key to close modals
24
+ - 🎭 **Custom Layouts**: Create your own modal layouts
25
+ - đŸĒļ **Lightweight**: Minimal dependencies
26
+ - đŸ’Ē **TypeScript**: Full type safety
27
+ - 🔄 **React 18 Compatible**: Built with useSyncExternalStore
28
+ - đŸŽ¯ **Zero runtime dependencies** (React as peer dependency)
29
+
30
+ ## Installation
31
+
32
+ ### Core Package
33
+
34
+ ```bash
35
+ npm install @ivex0002/stack-modal
36
+ ```
37
+
38
+ ### Presets (Optional)
39
+
40
+ If you want to use built-in presets:
41
+
42
+ ```bash
43
+ npm install @ivex0002/stack-modal-presets
44
+ ```
45
+
46
+ > **Note**: Presets are optional. If you're creating custom layouts, you don't need to install this presets package.
47
+
48
+ ## Quick Start
49
+
50
+ ```typescript
51
+ import { createModalStack } from "@ivex0002/stack-modal";
52
+ import { defaultPreset } from "@ivex0002/stack-modal-presets";
53
+
54
+ // Define your modals
55
+ const modals = {
56
+ alert: ({ message }: { message: string }) => (
57
+ <div>
58
+ <h2>Alert</h2>
59
+ <p>{message}</p>
60
+ </div>
61
+ ),
62
+ confirm: ({ title, onConfirm }: { title: string; onConfirm: () => void }) => (
63
+ <div>
64
+ <h2>{title}</h2>
65
+ <button onClick={onConfirm}>Confirm</button>
66
+ </div>
67
+ ),
68
+ };
69
+
70
+ // Create modal instance with preset
71
+ const modal = createModalStack(modals, defaultPreset);
72
+
73
+ // Use in your component
74
+ function App() {
75
+ return (
76
+ <div>
77
+ <button onClick={() => modal.alert.push({ message: "Hello!" })}>
78
+ Show Alert
79
+ </button>
80
+ <button
81
+ onClick={() =>
82
+ modal.confirm.push({
83
+ title: "Are you sure?",
84
+ onConfirm: () => console.log("Confirmed!"),
85
+ })
86
+ }
87
+ >
88
+ Show Confirm
89
+ </button>
90
+ </div>
91
+ );
92
+ }
93
+ ```
94
+
95
+ ## Presets
96
+
97
+ ### Default Preset
98
+
99
+ A centered modal with stacking animation. Multiple modals are displayed with offset and scale effects.
100
+
101
+ ```typescript
102
+ import { defaultPreset } from "@ivex0002/stack-modal-presets";
103
+
104
+ const modal = createModalStack(modals, defaultPreset);
105
+ ```
106
+
107
+ ### Minimal Preset
108
+
109
+ A simple centered modal with fade transitions. Only the top modal is visible.
110
+
111
+ ```typescript
112
+ import { minimalPreset } from "@ivex0002/stack-modal-presets";
113
+
114
+ const modal = createModalStack(modals, minimalPreset);
115
+ ```
116
+
117
+ ### Drawer Preset
118
+
119
+ A bottom sheet style modal that slides up from the bottom of the screen.
120
+
121
+ ```typescript
122
+ import { drawerPreset } from "@ivex0002/stack-modal-presets";
123
+
124
+ const modal = createModalStack(modals, drawerPreset);
125
+ ```
126
+
127
+ ## API Reference
128
+
129
+ ### `createModalStack(modals, modalLayout)`
130
+
131
+ Creates a modal instance with the specified modals and layout.
132
+
133
+ **Parameters:**
134
+
135
+ - `modals`: An object where keys are modal names and values are React components
136
+ - `modalLayout`: A layout configuration object (use presets or create custom)
137
+
138
+ **Returns:**
139
+
140
+ - Modal control object with `push` methods for each modal and a `pop` method
141
+
142
+ ### Modal Control Object
143
+
144
+ ```typescript
145
+ const modal = createModalStack(modals, preset);
146
+
147
+ // Push a modal
148
+ modal.modalName.push(props);
149
+
150
+ // Close the top modal
151
+ modal.pop();
152
+ ```
153
+
154
+ ## Custom Layout
155
+
156
+ You can create your own modal layout by implementing the `ModalLayout` interface.
157
+
158
+ this example made with tailwindcss & framer-motion.
159
+
160
+ ```typescript
161
+ import { motion } from "framer-motion";
162
+ import React from "react";
163
+ import type { ModalLayout } from "@ivex0002/stack-modal";
164
+
165
+ const STACK_OFFSET = 80;
166
+ const SCALE_STEP = 0.06;
167
+ const OPACITY_STEP = 0.08;
168
+
169
+ export const twfmModalLayoutExample: ModalLayout = {
170
+ Background: ({
171
+ children,
172
+ onClose,
173
+ }: {
174
+ children: React.ReactNode;
175
+ onClose: () => void;
176
+ }) => {
177
+ React.useEffect(() => {
178
+ const onKey = (e: KeyboardEvent) => {
179
+ if (e.key === "Escape") onClose();
180
+ };
181
+ window.addEventListener("keyup", onKey);
182
+ return () => window.removeEventListener("keyup", onKey);
183
+ }, [onClose]);
184
+
185
+ return (
186
+ <motion.div
187
+ className="fixed inset-0 z-1000 flex items-center justify-center backdrop-blur-md"
188
+ style={{
189
+ background:
190
+ "linear-gradient(to bottom right, rgba(99, 102, 241, 0.2), rgba(168, 85, 247, 0.2), rgba(236, 72, 153, 0.2))",
191
+ }}
192
+ initial={{ opacity: 0 }}
193
+ animate={{ opacity: 1 }}
194
+ exit={{ opacity: 0 }}
195
+ transition={{ duration: 0.3 }}
196
+ onClick={onClose}
197
+ >
198
+ <motion.div
199
+ className="absolute inset-0 bg-black/40"
200
+ initial={{ opacity: 0 }}
201
+ animate={{ opacity: 1 }}
202
+ exit={{ opacity: 0 }}
203
+ />
204
+ <div onClick={(e) => e.stopPropagation()}>{children}</div>
205
+ </motion.div>
206
+ );
207
+ },
208
+
209
+ ModalWrap: ({
210
+ children,
211
+ depth,
212
+ isTop,
213
+ }: {
214
+ children: React.ReactNode;
215
+ depth: number;
216
+ isTop: boolean;
217
+ }) => {
218
+ const x = -depth * STACK_OFFSET;
219
+ const scale = 1 - depth * SCALE_STEP;
220
+ const opacity = 1 - depth * OPACITY_STEP;
221
+
222
+ return (
223
+ <motion.div
224
+ className="absolute w-auto max-w-[90vw] rounded-3xl bg-white p-8 ring-1 ring-black/5"
225
+ style={{
226
+ left: "50%",
227
+ top: "50%",
228
+ pointerEvents: isTop ? "auto" : "none",
229
+ background:
230
+ "linear-gradient(to bottom right, white, white, rgb(249, 250, 251))",
231
+ boxShadow: isTop
232
+ ? "0 20px 70px -10px rgba(0,0,0,0.3), 0 0 0 1px rgba(99,102,241,0.1)"
233
+ : "0 10px 40px -10px rgba(0,0,0,0.2)",
234
+ }}
235
+ initial={{
236
+ opacity: 0,
237
+ scale: 0.9,
238
+ x: "-50%",
239
+ y: "-45%",
240
+ }}
241
+ animate={{
242
+ opacity,
243
+ scale,
244
+ x: `calc(-50% + ${x}px)`,
245
+ y: "-50%",
246
+ }}
247
+ exit={{
248
+ opacity: 0,
249
+ scale: 0.9,
250
+ x: "-50%",
251
+ y: "-45%",
252
+ }}
253
+ transition={{
254
+ type: "spring",
255
+ stiffness: 280,
256
+ damping: 28,
257
+ mass: 0.8,
258
+ }}
259
+ >
260
+ <div
261
+ className="absolute inset-0 rounded-3xl pointer-events-none"
262
+ style={{
263
+ background:
264
+ "linear-gradient(to bottom right, rgba(99, 102, 241, 0.05), transparent, rgba(168, 85, 247, 0.05))",
265
+ }}
266
+ />
267
+ <div
268
+ className="absolute -top-px left-1/2 -translate-x-1/2 w-1/2 h-px"
269
+ style={{
270
+ background:
271
+ "linear-gradient(to right, transparent, rgba(99, 102, 241, 0.5), transparent)",
272
+ }}
273
+ />
274
+
275
+ <div className="relative z-10">{children}</div>
276
+ </motion.div>
277
+ );
278
+ },
279
+ };
280
+
281
+ const modal = createModalStack(modals, twfmModalLayoutExample);
282
+ ```
283
+
284
+ ### ModalLayout Interface
285
+
286
+ ```typescript
287
+ type ModalLayout = {
288
+ Background: React.ComponentType<{
289
+ children: React.ReactNode;
290
+ onClose: () => void;
291
+ }>;
292
+ ModalWrap: React.ComponentType<{
293
+ children: React.ReactNode;
294
+ depth: number;
295
+ isTop: boolean;
296
+ }>;
297
+ };
298
+ ```
299
+
300
+ - **Background**: The backdrop and container for all modals
301
+
302
+ - `onClose`: Function to close the top modal
303
+ - `children`: The modal content
304
+
305
+ - **ModalWrap**: Wrapper for each individual modal
306
+ - `depth`: The position in the stack (0 = top, 1 = second from top, etc.)
307
+ - `isTop`: Boolean indicating if this is the topmost modal
308
+ - `children`: The modal content
309
+
310
+ ## Examples
311
+
312
+ ### Alert Modal
313
+
314
+ ```typescript
315
+ const modals = {
316
+ alert: ({ message }: { message: string }) => (
317
+ <div style={{ padding: "20px" }}>
318
+ <h2>Alert</h2>
319
+ <p>{message}</p>
320
+ <button onClick={() => modal.pop()}>OK</button>
321
+ </div>
322
+ ),
323
+ };
324
+
325
+ // Usage
326
+ modal.alert.push({ message: "Something happened!" });
327
+ ```
328
+
329
+ ### Confirmation Modal
330
+
331
+ ```typescript
332
+ const modals = {
333
+ confirm: ({
334
+ title,
335
+ message,
336
+ onConfirm,
337
+ }: {
338
+ title: string;
339
+ message: string;
340
+ onConfirm: () => void;
341
+ }) => (
342
+ <div style={{ padding: "20px" }}>
343
+ <h2>{title}</h2>
344
+ <p>{message}</p>
345
+ <div style={{ display: "flex", gap: "10px" }}>
346
+ <button
347
+ onClick={() => {
348
+ onConfirm();
349
+ modal.pop();
350
+ }}
351
+ >
352
+ Confirm
353
+ </button>
354
+ <button onClick={() => modal.pop()}>Cancel</button>
355
+ </div>
356
+ </div>
357
+ ),
358
+ };
359
+
360
+ // Usage
361
+ modal.confirm.push({
362
+ title: "Delete Item",
363
+ message: "Are you sure you want to delete this item?",
364
+ onConfirm: () => console.log("Item deleted"),
365
+ });
366
+ ```
367
+
368
+ ### Stacked Modals
369
+
370
+ ```typescript
371
+ // Open multiple modals
372
+ modal.first.push({ data: "First modal" });
373
+ modal.second.push({ data: "Second modal" });
374
+ modal.third.push({ data: "Third modal" });
375
+
376
+ // Close them one by one
377
+ modal.pop(); // Closes third
378
+ modal.pop(); // Closes second
379
+ modal.pop(); // Closes first
380
+ ```
381
+
382
+ ## TypeScript Support
383
+
384
+ Modal Stack is written in TypeScript and provides full type safety:
385
+
386
+ ```typescript
387
+ // Props are fully typed
388
+ const modals = {
389
+ user: ({ name, age }: { name: string; age: number }) => (
390
+ <div>
391
+ <p>Name: {name}</p>
392
+ <p>Age: {age}</p>
393
+ </div>
394
+ ),
395
+ };
396
+
397
+ const modal = createModalStack(modals, defaultPreset);
398
+
399
+ // ✅ Correct
400
+ modal.user.push({ name: "John", age: 30 });
401
+
402
+ // ❌ TypeScript error
403
+ modal.user.push({ name: "John" }); // Missing 'age'
404
+ modal.user.push({ name: "John", age: "30" }); // Wrong type
405
+ ```
406
+
407
+ ## How It Works
408
+
409
+ Modal Stack uses a simple but powerful architecture to manage modal states and rendering.
410
+
411
+ ### Core Components
412
+
413
+ #### 1. Stack Store
414
+
415
+ The store manages the modal stack state using a simple array:
416
+
417
+ ```typescript
418
+ type StackItem = {
419
+ key: string;
420
+ props: unknown;
421
+ };
422
+
423
+ export const createStackStore = () => {
424
+ let stack: StackItem[] = [];
425
+ const listeners = new Set<() => void>();
426
+ return {
427
+ push(item: StackItem) {
428
+ stack = [...stack, item];
429
+ listeners.forEach((l) => l());
430
+ },
431
+ pop() {
432
+ stack = stack.slice(0, -1);
433
+ listeners.forEach((l) => l());
434
+ },
435
+ get() {
436
+ return stack;
437
+ },
438
+ subscribe(fn: () => void) {
439
+ listeners.add(fn);
440
+ return () => listeners.delete(fn);
441
+ },
442
+ };
443
+ };
444
+ ```
445
+
446
+ #### 2. Modal Renderer
447
+
448
+ The `ModalRender` component subscribes to the store and renders the modal stack:
449
+
450
+ ```typescript
451
+ type ModalRegistry = {
452
+ [key: string]: (props?: unknown) => React.ReactNode;
453
+ };
454
+ type ModalLayout = {
455
+ Background: React.ComponentType<{
456
+ children: React.ReactNode;
457
+ onClose: () => void;
458
+ }>;
459
+ ModalWrap: React.ComponentType<{
460
+ children: React.ReactNode;
461
+ depth: number;
462
+ isTop: boolean;
463
+ }>;
464
+ };
465
+
466
+ function ModalRender<M extends ModalRegistry>({
467
+ modals,
468
+ modalLayout,
469
+ store,
470
+ }: {
471
+ modals: M;
472
+ modalLayout: ModalLayout;
473
+ store: StackStore;
474
+ }) {
475
+ // Subscribe to store changes using React 18's useSyncExternalStore
476
+ const stack = React.useSyncExternalStore(
477
+ store.subscribe, // Subscribe function
478
+ store.get, // Get snapshot (client)
479
+ store.get // Get snapshot (server)
480
+ );
481
+
482
+ if (stack.length === 0) return null;
483
+
484
+ return (
485
+ <modalLayout.Background onClose={() => store.pop()}>
486
+ {stack.map((item, index) => {
487
+ // Calculate depth: top modal = 0, second = 1, etc.
488
+ const depth = stack.length - 1 - index;
489
+
490
+ return (
491
+ <modalLayout.ModalWrap
492
+ depth={depth}
493
+ isTop={index === stack.length - 1}
494
+ key={index}
495
+ >
496
+ {/* Dynamically render the modal component */}
497
+ {modals[item.key](item.props)}
498
+ </modalLayout.ModalWrap>
499
+ );
500
+ })}
501
+ </modalLayout.Background>
502
+ );
503
+ }
504
+ ```
505
+
506
+ **Key features:**
507
+
508
+ - Uses `useSyncExternalStore` for optimal performance and React 18 compatibility
509
+ - Calculates `depth` for each modal (0 = top, higher = deeper in stack)
510
+ - Identifies the `isTop` modal for interaction control
511
+ - Dynamically renders modal components based on the stack state
512
+
513
+ #### 3. Modal Creation Flow
514
+
515
+ ```typescript
516
+ // 1. Define modals
517
+ const modals = {
518
+ alert: (props) => <AlertComponent {...props} />,
519
+ confirm: (props) => <ConfirmComponent {...props} />,
520
+ };
521
+
522
+ // 2. Create modal instance
523
+ const modal = createModalStack(modals, preset);
524
+
525
+ // This creates:
526
+ // - A store to manage modal stack
527
+ // - A root DOM element to render modals
528
+ // - Push methods for each modal type
529
+ // - A pop method to close modals
530
+
531
+ // 3. Use modals
532
+ modal.alert.push({ message: "Hello" });
533
+ // -> Adds { key: "alert", props: { message: "Hello" } } to stack
534
+ // -> Triggers re-render
535
+ // -> ModalRender displays the modal
536
+
537
+ modal.pop();
538
+ // -> Removes top item from stack
539
+ // -> Triggers re-render
540
+ // -> Modal disappears
541
+ ```
542
+
543
+ ### Rendering Process
544
+
545
+ 1. **Initial State**: Stack is empty, nothing renders
546
+ 2. **Push Modal**:
547
+ - Modal data added to stack
548
+ - Listeners notified
549
+ - `ModalRender` re-renders with new stack
550
+ - `Background` and `ModalWrap` components render the modal
551
+ 3. **Stack Multiple Modals**:
552
+ - Each modal gets a `depth` value
553
+ - Presets use `depth` to position/style modals
554
+ - Only top modal (`isTop={true}`) receives pointer events
555
+ 4. **Pop Modal**:
556
+ - Top item removed from stack
557
+ - Component re-renders
558
+ - If stack is empty, returns `null`
559
+
560
+ ### Why useSyncExternalStore?
561
+
562
+ `useSyncExternalStore` is a React 18 hook that safely subscribes to external stores:
563
+
564
+ - **Tearing prevention**: Ensures consistent state across concurrent renders
565
+ - **SSR support**: Separate snapshots for server and client
566
+ - **Performance**: Only re-renders when subscribed state changes
567
+
568
+ ## Troubleshooting
569
+
570
+ ### Modals not appearing?
571
+
572
+ Make sure `createModalStack` is called at the module level, not inside a component:
573
+
574
+ ```typescript
575
+ // ✅ Correct
576
+ const modal = createModalStack(modals, preset);
577
+
578
+ function App() {
579
+ return <button onClick={() => modal.alert.push()}>Open</button>;
580
+ }
581
+
582
+ // ❌ Wrong - creates new instance on every render
583
+ function App() {
584
+ const modal = createModalStack(modals, preset);
585
+ return <button onClick={() => modal.alert.push()}>Open</button>;
586
+ }
587
+ ```
588
+
589
+ ## License
590
+
591
+ MIT
592
+
593
+ ## Contributing
594
+
595
+ Contributions are welcome! Please feel free to submit a Pull Request.
package/dist/index.cjs ADDED
@@ -0,0 +1,120 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createStackModal: () => createStackModal
34
+ });
35
+ module.exports = __toCommonJS(index_exports);
36
+
37
+ // src/ensureRoot.ts
38
+ var import_client = require("react-dom/client");
39
+ function ensureRoot(render) {
40
+ const container = document.createElement("div");
41
+ container.setAttribute("data-modal-root", "true");
42
+ document.body.appendChild(container);
43
+ const root = (0, import_client.createRoot)(container);
44
+ root.render(render());
45
+ return {
46
+ destroy: () => {
47
+ root.unmount();
48
+ container.remove();
49
+ }
50
+ };
51
+ }
52
+
53
+ // src/ModalRender.tsx
54
+ var import_react = __toESM(require("react"), 1);
55
+ var import_jsx_runtime = require("react/jsx-runtime");
56
+ function ModalRender({
57
+ modals,
58
+ modalLayout,
59
+ store
60
+ }) {
61
+ const stack = import_react.default.useSyncExternalStore(
62
+ store.subscribe,
63
+ store.get,
64
+ store.get
65
+ );
66
+ if (stack.length === 0) return null;
67
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(modalLayout.Background, { onClose: () => store.pop(), children: stack.map((item, index) => {
68
+ const depth = stack.length - 1 - index;
69
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
70
+ modalLayout.ModalWrap,
71
+ {
72
+ depth,
73
+ isTop: index === stack.length - 1,
74
+ children: modals[item.key](item.props)
75
+ },
76
+ index
77
+ );
78
+ }) });
79
+ }
80
+
81
+ // src/createStackStore.ts
82
+ var createStackStore = () => {
83
+ let stack = [];
84
+ const listeners = /* @__PURE__ */ new Set();
85
+ return {
86
+ push(item) {
87
+ stack = [...stack, item];
88
+ listeners.forEach((l) => l());
89
+ },
90
+ pop() {
91
+ stack = stack.slice(0, -1);
92
+ listeners.forEach((l) => l());
93
+ },
94
+ get() {
95
+ return stack;
96
+ },
97
+ subscribe(fn) {
98
+ listeners.add(fn);
99
+ return () => listeners.delete(fn);
100
+ }
101
+ };
102
+ };
103
+
104
+ // src/createStackModal.tsx
105
+ var import_jsx_runtime2 = require("react/jsx-runtime");
106
+ function createStackModal(modals, modalLayout) {
107
+ const store = createStackStore();
108
+ ensureRoot(() => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(ModalRender, { modals, modalLayout, store }));
109
+ const modal = {};
110
+ Object.keys(modals).forEach((key) => {
111
+ modal[key] = {
112
+ push: (props) => store.push({ key, props })
113
+ };
114
+ });
115
+ return { ...modal, pop: () => store.pop() };
116
+ }
117
+ // Annotate the CommonJS export names for ESM import in node:
118
+ 0 && (module.exports = {
119
+ createStackModal
120
+ });
@@ -0,0 +1,24 @@
1
+ type ModalRegistry = {
2
+ [key: string]: (props?: unknown) => React.ReactNode;
3
+ };
4
+ type AnyFn = (...args: unknown[]) => unknown;
5
+ type PushFn<F extends AnyFn> = Parameters<F> extends [] ? () => void : (props?: Parameters<F>[0]) => void;
6
+ type ModalLayout = {
7
+ Background: React.ComponentType<{
8
+ children: React.ReactNode;
9
+ onClose: () => void;
10
+ }>;
11
+ ModalWrap: React.ComponentType<{
12
+ children: React.ReactNode;
13
+ depth: number;
14
+ isTop: boolean;
15
+ }>;
16
+ };
17
+
18
+ declare function createStackModal<M extends ModalRegistry = ModalRegistry>(modals: M, modalLayout: ModalLayout): { [K in Extract<keyof M, string>]: {
19
+ push: PushFn<M[K]>;
20
+ }; } & {
21
+ pop: () => void;
22
+ };
23
+
24
+ export { type ModalLayout, createStackModal };
@@ -0,0 +1,24 @@
1
+ type ModalRegistry = {
2
+ [key: string]: (props?: unknown) => React.ReactNode;
3
+ };
4
+ type AnyFn = (...args: unknown[]) => unknown;
5
+ type PushFn<F extends AnyFn> = Parameters<F> extends [] ? () => void : (props?: Parameters<F>[0]) => void;
6
+ type ModalLayout = {
7
+ Background: React.ComponentType<{
8
+ children: React.ReactNode;
9
+ onClose: () => void;
10
+ }>;
11
+ ModalWrap: React.ComponentType<{
12
+ children: React.ReactNode;
13
+ depth: number;
14
+ isTop: boolean;
15
+ }>;
16
+ };
17
+
18
+ declare function createModalStack<M extends ModalRegistry = ModalRegistry>(modals: M, modalLayout: ModalLayout): { [K in Extract<keyof M, string>]: {
19
+ push: PushFn<M[K]>;
20
+ }; } & {
21
+ pop: () => void;
22
+ };
23
+
24
+ export { type ModalLayout, createModalStack };
@@ -0,0 +1,24 @@
1
+ type ModalRegistry = {
2
+ [key: string]: (props?: unknown) => React.ReactNode;
3
+ };
4
+ type AnyFn = (...args: unknown[]) => unknown;
5
+ type PushFn<F extends AnyFn> = Parameters<F> extends [] ? () => void : (props?: Parameters<F>[0]) => void;
6
+ type ModalLayout = {
7
+ Background: React.ComponentType<{
8
+ children: React.ReactNode;
9
+ onClose: () => void;
10
+ }>;
11
+ ModalWrap: React.ComponentType<{
12
+ children: React.ReactNode;
13
+ depth: number;
14
+ isTop: boolean;
15
+ }>;
16
+ };
17
+
18
+ declare function createStackModal<M extends ModalRegistry = ModalRegistry>(modals: M, modalLayout: ModalLayout): { [K in Extract<keyof M, string>]: {
19
+ push: PushFn<M[K]>;
20
+ }; } & {
21
+ pop: () => void;
22
+ };
23
+
24
+ export { type ModalLayout, createStackModal };
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ // src/ensureRoot.ts
2
+ import { createRoot } from "react-dom/client";
3
+ function ensureRoot(render) {
4
+ const container = document.createElement("div");
5
+ container.setAttribute("data-modal-root", "true");
6
+ document.body.appendChild(container);
7
+ const root = createRoot(container);
8
+ root.render(render());
9
+ return {
10
+ destroy: () => {
11
+ root.unmount();
12
+ container.remove();
13
+ }
14
+ };
15
+ }
16
+
17
+ // src/ModalRender.tsx
18
+ import React from "react";
19
+ import { jsx } from "react/jsx-runtime";
20
+ function ModalRender({
21
+ modals,
22
+ modalLayout,
23
+ store
24
+ }) {
25
+ const stack = React.useSyncExternalStore(
26
+ store.subscribe,
27
+ store.get,
28
+ store.get
29
+ );
30
+ if (stack.length === 0) return null;
31
+ return /* @__PURE__ */ jsx(modalLayout.Background, { onClose: () => store.pop(), children: stack.map((item, index) => {
32
+ const depth = stack.length - 1 - index;
33
+ return /* @__PURE__ */ jsx(
34
+ modalLayout.ModalWrap,
35
+ {
36
+ depth,
37
+ isTop: index === stack.length - 1,
38
+ children: modals[item.key](item.props)
39
+ },
40
+ index
41
+ );
42
+ }) });
43
+ }
44
+
45
+ // src/createStackStore.ts
46
+ var createStackStore = () => {
47
+ let stack = [];
48
+ const listeners = /* @__PURE__ */ new Set();
49
+ return {
50
+ push(item) {
51
+ stack = [...stack, item];
52
+ listeners.forEach((l) => l());
53
+ },
54
+ pop() {
55
+ stack = stack.slice(0, -1);
56
+ listeners.forEach((l) => l());
57
+ },
58
+ get() {
59
+ return stack;
60
+ },
61
+ subscribe(fn) {
62
+ listeners.add(fn);
63
+ return () => listeners.delete(fn);
64
+ }
65
+ };
66
+ };
67
+
68
+ // src/createStackModal.tsx
69
+ import { jsx as jsx2 } from "react/jsx-runtime";
70
+ function createStackModal(modals, modalLayout) {
71
+ const store = createStackStore();
72
+ ensureRoot(() => /* @__PURE__ */ jsx2(ModalRender, { modals, modalLayout, store }));
73
+ const modal = {};
74
+ Object.keys(modals).forEach((key) => {
75
+ modal[key] = {
76
+ push: (props) => store.push({ key, props })
77
+ };
78
+ });
79
+ return { ...modal, pop: () => store.pop() };
80
+ }
81
+ export {
82
+ createStackModal
83
+ };
package/dist/index.mjs ADDED
@@ -0,0 +1,83 @@
1
+ // src/ensureRoot.ts
2
+ import { createRoot } from "react-dom/client";
3
+ function ensureRoot(render) {
4
+ const container = document.createElement("div");
5
+ container.setAttribute("data-modal-root", "true");
6
+ document.body.appendChild(container);
7
+ const root = createRoot(container);
8
+ root.render(render());
9
+ return {
10
+ destroy: () => {
11
+ root.unmount();
12
+ container.remove();
13
+ }
14
+ };
15
+ }
16
+
17
+ // src/ModalRender.tsx
18
+ import React from "react";
19
+ import { jsx } from "react/jsx-runtime";
20
+ function ModalRender({
21
+ modals,
22
+ modalLayout,
23
+ store
24
+ }) {
25
+ const stack = React.useSyncExternalStore(
26
+ store.subscribe,
27
+ store.get,
28
+ store.get
29
+ );
30
+ if (stack.length === 0) return null;
31
+ return /* @__PURE__ */ jsx(modalLayout.Background, { onClose: () => store.pop(), children: stack.map((item, index) => {
32
+ const depth = stack.length - 1 - index;
33
+ return /* @__PURE__ */ jsx(
34
+ modalLayout.ModalWrap,
35
+ {
36
+ depth,
37
+ isTop: index === stack.length - 1,
38
+ children: modals[item.key](item.props)
39
+ },
40
+ index
41
+ );
42
+ }) });
43
+ }
44
+
45
+ // src/createStackStore.ts
46
+ var createStackStore = () => {
47
+ let stack = [];
48
+ const listeners = /* @__PURE__ */ new Set();
49
+ return {
50
+ push(item) {
51
+ stack = [...stack, item];
52
+ listeners.forEach((l) => l());
53
+ },
54
+ pop() {
55
+ stack = stack.slice(0, -1);
56
+ listeners.forEach((l) => l());
57
+ },
58
+ get() {
59
+ return stack;
60
+ },
61
+ subscribe(fn) {
62
+ listeners.add(fn);
63
+ return () => listeners.delete(fn);
64
+ }
65
+ };
66
+ };
67
+
68
+ // src/createModalStack.tsx
69
+ import { jsx as jsx2 } from "react/jsx-runtime";
70
+ function createModalStack(modals, modalLayout) {
71
+ const store = createStackStore();
72
+ ensureRoot(() => /* @__PURE__ */ jsx2(ModalRender, { modals, modalLayout, store }));
73
+ const modal = {};
74
+ Object.keys(modals).forEach((key) => {
75
+ modal[key] = {
76
+ push: (props) => store.push({ key, props })
77
+ };
78
+ });
79
+ return { ...modal, pop: () => store.pop() };
80
+ }
81
+ export {
82
+ createModalStack
83
+ };
package/package.json ADDED
@@ -0,0 +1,60 @@
1
+ {
2
+ "name": "@ivex0002/stack-modal",
3
+ "version": "1.0.0",
4
+ "description": "A flexible and customizable modal stack management library for React",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.mjs",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.mjs",
13
+ "require": "./dist/index.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "dist",
18
+ "README.md"
19
+ ],
20
+ "keywords": [
21
+ "stack-modal",
22
+ "stack-modal-presets",
23
+ "react",
24
+ "modal",
25
+ "stack",
26
+ "dialog",
27
+ "popup",
28
+ "typescript",
29
+ "ui"
30
+ ],
31
+ "author": "ivex0002",
32
+ "license": "MIT",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "https://github.com/Ivex0002/stack-modal"
36
+ },
37
+ "bugs": {
38
+ "url": "https://github.com/Ivex0002/stack-modal/issues"
39
+ },
40
+ "homepage": "https://github.com/Ivex0002/stack-modal#readme",
41
+ "publishConfig": {
42
+ "access": "public"
43
+ },
44
+ "peerDependencies": {
45
+ "react": ">=18.0.0",
46
+ "react-dom": ">=18.0.0"
47
+ },
48
+ "devDependencies": {
49
+ "@types/react": "^18.0.0",
50
+ "@types/react-dom": "^18.0.0",
51
+ "react": "^18.0.0",
52
+ "react-dom": "^18.0.0",
53
+ "tsup": "^8.0.0",
54
+ "typescript": "^5.0.0"
55
+ },
56
+ "scripts": {
57
+ "build": "tsup src/index.ts --format cjs,esm --dts",
58
+ "pack:test": "npm run build && npm pack"
59
+ }
60
+ }