fine-modal-react 0.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,123 @@
1
+ # Fine Modal React
2
+
3
+ Typed, promise-based modals for React 19+ with two mounting strategies: a global host or colocated modal components.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install fine-modal-react
9
+ # or
10
+ pnpm add fine-modal-react
11
+ ```
12
+
13
+ Peer deps: `react` and `react-dom` (19.x).
14
+
15
+ ## Define a modal (shared for both strategies)
16
+
17
+ ```tsx
18
+ import { FineModal } from 'fine-modal-react'
19
+
20
+ export const ConfirmInviteModal = FineModal.define({
21
+ id: 'ConfirmInviteModal',
22
+ component: ({ initialProps, onConfirm, onCancel }: {
23
+ initialProps: { email: string }
24
+ onConfirm: (value: 'sent') => void
25
+ onCancel: () => void
26
+ }) => (
27
+ <section>
28
+ <p>Send an invite to {initialProps.email}?</p>
29
+ <div>
30
+ <button type="button" onClick={() => onConfirm('sent')}>Send</button>
31
+ <button type="button" onClick={onCancel}>Cancel</button>
32
+ </div>
33
+ </section>
34
+ ),
35
+ })
36
+
37
+ export const modals = [ConfirmInviteModal] as const
38
+ ```
39
+
40
+ ### (Optional) TypeScript registration for typed `open`
41
+
42
+ ```ts
43
+ import type { modals } from './modals'
44
+
45
+ declare module 'fine-modal-react' {
46
+ interface Register {
47
+ readonly modals?: typeof modals
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Option A: Global host (central place for all modals)
53
+
54
+ Use a single `ModalHost` near the app root. Open modals anywhere via their string id.
55
+
56
+ ```tsx
57
+ import { StrictMode } from 'react'
58
+ import { createRoot } from 'react-dom/client'
59
+ import { FineModal } from 'fine-modal-react'
60
+ import App from './app'
61
+ import { modals } from './modals'
62
+
63
+ const ModalHost = FineModal.createHost({ modals })
64
+
65
+ createRoot(document.getElementById('root')!).render(
66
+ <StrictMode>
67
+ <App />
68
+ <ModalHost />
69
+ </StrictMode>
70
+ )
71
+ ```
72
+
73
+ ```tsx
74
+ // anywhere in the tree
75
+ import { FineModal } from 'fine-modal-react'
76
+
77
+ export function App() {
78
+ const handleInvite = async () => {
79
+ const result = await FineModal.open('ConfirmInviteModal', {
80
+ email: 'teammate@company.com',
81
+ })
82
+
83
+ if (result === 'sent') {
84
+ console.log('Invite sent')
85
+ } else {
86
+ console.log('Invite cancelled')
87
+ }
88
+ }
89
+
90
+ return <button onClick={handleInvite}>Invite teammate</button>
91
+ }
92
+ ```
93
+
94
+ ## Option B: Local modal component (colocated scope)
95
+
96
+ Render the modal component where you need it; open it via its static API. This avoids a global host if you only need the modal in one subtree.
97
+
98
+ ```tsx
99
+ import { ConfirmInviteModal } from './modals'
100
+
101
+ export function App() {
102
+ const handleInvite = async () => {
103
+ const result = await ConfirmInviteModal.open({ email: 'new.user@org.com' })
104
+ if (result === 'sent') {
105
+ console.log('Invite sent')
106
+ }
107
+ }
108
+
109
+ return (
110
+ <>
111
+ <button onClick={handleInvite}>Invite teammate</button>
112
+ <ConfirmInviteModal />
113
+ </>
114
+ )
115
+ }
116
+ ```
117
+
118
+ ## Modal authoring notes
119
+
120
+ - `onConfirm(value)` resolves the promise returned by `open` with `value`.
121
+ - `onCancel()` resolves the promise with `null` and closes the modal.
122
+ - If your modal needs initial props, add an `initialProps` field to the component props; `open` will require/accept that shape.
123
+ - `FineModal.open` automatically closes an existing modal with the same id before opening a new one.
@@ -0,0 +1,47 @@
1
+ import React from "react";
2
+ import * as react_jsx_runtime0 from "react/jsx-runtime";
3
+
4
+ //#region src/index.d.ts
5
+ interface ModalStatics<Id extends string, InitialProps, Result> {
6
+ id: Id;
7
+ open: (...args: [InitialProps] extends [void] ? [] : [InitialProps] extends [undefined] ? [] : [props: InitialProps]) => Promise<Result | null>;
8
+ close: () => void;
9
+ }
10
+ type ExtractInitialProps<C> = C extends React.ComponentType<infer P> ? P extends {
11
+ initialProps: infer IP;
12
+ } ? IP : never : never;
13
+ type ExtractResult<C> = C extends React.ComponentType<infer P> ? P extends {
14
+ onConfirm: (value: infer R) => any;
15
+ } ? R : never : never;
16
+ interface Register {}
17
+ type GetProp<T, K extends PropertyKey> = K extends keyof T ? T[K] : never;
18
+ type RegisteredModals = NonNullable<GetProp<Register, 'modals'>> extends readonly unknown[] ? NonNullable<GetProp<Register, 'modals'>>[number] : never;
19
+ type ModalMap = { [M in RegisteredModals as M extends {
20
+ id: infer N;
21
+ } ? N extends string ? N : never : never]: M };
22
+ type RegisteredIds = keyof ModalMap;
23
+ type PropsById<N extends RegisteredIds> = ModalMap[N] extends {
24
+ open: (p: infer P) => any;
25
+ } ? P : never;
26
+ type ResultById<N extends RegisteredIds> = ModalMap[N] extends {
27
+ open: (...a: any[]) => Promise<infer R>;
28
+ } ? R : never;
29
+ type PropsArg<N extends RegisteredIds> = [PropsById<N>] extends [never] ? [] : [undefined] extends [PropsById<N>] ? [] : [void] extends [PropsById<N>] ? [] : [props: PropsById<N>];
30
+ type AnyModal = React.FC<any> & {
31
+ id: string;
32
+ };
33
+ interface CreateHostOptions {
34
+ modals: readonly AnyModal[];
35
+ }
36
+ declare const obj: {
37
+ open<N extends RegisteredIds>(id: N, ...args: PropsArg<N>): Promise<ResultById<N>>;
38
+ close<N extends RegisteredIds>(id: string, value: ResultById<N> | null): void;
39
+ } & {
40
+ define: <const Id extends string, C extends React.ComponentType<any>>(options: {
41
+ id: Id;
42
+ component: C;
43
+ }) => ((props: Omit<React.ComponentProps<C>, "initialProps" | "onConfirm" | "onCancel">) => false | react_jsx_runtime0.JSX.Element) & ModalStatics<Id, ExtractInitialProps<C>, ExtractResult<C>>;
44
+ createHost: (options: CreateHostOptions) => () => react_jsx_runtime0.JSX.Element[];
45
+ };
46
+ //#endregion
47
+ export { obj as FineModal, Register };
package/dist/index.js ADDED
@@ -0,0 +1,88 @@
1
+ import React, { useCallback, useMemo, useSyncExternalStore } from "react";
2
+ import { jsx } from "react/jsx-runtime";
3
+
4
+ //#region src/index.tsx
5
+ const createStore = () => {
6
+ let listeners = [];
7
+ const store$1 = {
8
+ modals: {},
9
+ subscribe: (cb) => {
10
+ listeners.push(cb);
11
+ return () => {
12
+ listeners = listeners.filter((listener) => listener !== cb);
13
+ };
14
+ },
15
+ getSnapshot: () => store$1.modals,
16
+ open: (id, props) => {
17
+ store$1.modals = {
18
+ ...store$1.modals,
19
+ [id]: props
20
+ };
21
+ listeners.forEach((cb) => cb());
22
+ },
23
+ close: (id) => {
24
+ const { [id]: _removed, ...rest } = store$1.modals;
25
+ store$1.modals = rest;
26
+ listeners.forEach((cb) => cb());
27
+ }
28
+ };
29
+ return store$1;
30
+ };
31
+ const store = createStore();
32
+ const resolves = {};
33
+ const FineModal = {
34
+ open: (id, props) => new Promise((r) => {
35
+ if (resolves[id]) FineModal.close(id, null);
36
+ resolves[id] = r;
37
+ store.open(id, props);
38
+ }),
39
+ close: (id, value = null) => {
40
+ resolves[id]?.(value);
41
+ store.close(id);
42
+ delete resolves[id];
43
+ }
44
+ };
45
+ const define = (options) => {
46
+ const Modal = (props) => {
47
+ const snap = useSyncExternalStore(store.subscribe, store.getSnapshot);
48
+ const id = options.id;
49
+ const initialProps = snap[id];
50
+ const handleConfirm = useCallback((value) => {
51
+ FineModal.close(id, value);
52
+ }, [id]);
53
+ const handleCancel = useCallback(() => {
54
+ FineModal.close(id, null);
55
+ }, [id]);
56
+ return initialProps !== null && /* @__PURE__ */ jsx(options.component, {
57
+ ...props,
58
+ initialProps,
59
+ onConfirm: handleConfirm,
60
+ onCancel: handleCancel
61
+ });
62
+ };
63
+ return Object.assign(Modal, {
64
+ id: options.id,
65
+ open: (...args) => FineModal.open(options.id, args[0] ?? {}),
66
+ close: () => FineModal.close(options.id, null)
67
+ });
68
+ };
69
+ const createHost = (options) => {
70
+ const modalsMap = options.modals.reduce((acc, modal) => ({
71
+ ...acc,
72
+ [modal.id]: modal
73
+ }), {});
74
+ return function ModalHost() {
75
+ const snap = useSyncExternalStore(store.subscribe, store.getSnapshot);
76
+ return useMemo(() => Object.entries(snap).filter(([id]) => id in modalsMap), [snap]).map(([id, props]) => {
77
+ const Modal = modalsMap[id];
78
+ return /* @__PURE__ */ jsx(Modal, { initialProps: props }, id);
79
+ });
80
+ };
81
+ };
82
+ const obj = Object.assign(FineModal, {
83
+ define,
84
+ createHost
85
+ });
86
+
87
+ //#endregion
88
+ export { obj as FineModal };
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "fine-modal-react",
3
+ "type": "module",
4
+ "version": "0.0.0",
5
+ "description": "Typed, promise-based modal utilities for React 19+.",
6
+ "author": "lorof",
7
+ "license": "MIT",
8
+ "homepage": "https://github.com/lorof/fine-modal-react#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/lorof/fine-modal-react.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/lorof/fine-modal-react/issues"
15
+ },
16
+ "keywords": [
17
+ "react",
18
+ "modal",
19
+ "dialog",
20
+ "typescript",
21
+ "promise"
22
+ ],
23
+ "exports": {
24
+ ".": "./dist/index.js",
25
+ "./package.json": "./package.json"
26
+ },
27
+ "main": "./dist/index.js",
28
+ "module": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "files": [
31
+ "dist"
32
+ ],
33
+ "publishConfig": {
34
+ "access": "public"
35
+ },
36
+ "scripts": {
37
+ "build": "tsdown ./src/index.tsx",
38
+ "dev": "tsdown --watch",
39
+ "play": "vite",
40
+ "test": "vitest",
41
+ "typecheck": "tsc --noEmit",
42
+ "release": "bumpp && npm publish",
43
+ "prepublishOnly": "npm run build"
44
+ },
45
+ "peerDependencies": {
46
+ "react": "^19.2.0",
47
+ "react-dom": "^19.2.0"
48
+ },
49
+ "devDependencies": {
50
+ "@tsconfig/strictest": "^2.0.8",
51
+ "@types/node": "^25.0.3",
52
+ "@types/react": "^19.2.7",
53
+ "@types/react-dom": "^19.2.3",
54
+ "@vitejs/plugin-react": "^5.1.2",
55
+ "@vitest/browser-playwright": "^4.0.16",
56
+ "bumpp": "^10.3.2",
57
+ "tsdown": "^0.18.1",
58
+ "typescript": "^5.9.3",
59
+ "vite": "^7.3.0",
60
+ "vitest": "^4.0.16",
61
+ "vitest-browser-react": "^2.0.2"
62
+ }
63
+ }