react-stateshape 0.2.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/LICENSE +21 -0
- package/README.md +475 -0
- package/dist/index.cjs +320 -0
- package/dist/index.d.ts +164 -0
- package/dist/index.mjs +300 -0
- package/index.ts +24 -0
- package/package.json +49 -0
- package/src/A.tsx +12 -0
- package/src/Area.tsx +15 -0
- package/src/RouteContext.ts +6 -0
- package/src/RouteProvider.tsx +30 -0
- package/src/TransientStateContext.ts +7 -0
- package/src/TransientStateProvider.tsx +40 -0
- package/src/URLContext.ts +6 -0
- package/src/URLProvider.tsx +28 -0
- package/src/types/AProps.ts +6 -0
- package/src/types/AreaProps.ts +6 -0
- package/src/types/EnhanceHref.ts +8 -0
- package/src/types/LinkNavigationProps.ts +8 -0
- package/src/types/RenderCallback.ts +4 -0
- package/src/types/TransientState.ts +6 -0
- package/src/useExternalState.ts +76 -0
- package/src/useLinkClick.ts +29 -0
- package/src/useNavigationComplete.ts +11 -0
- package/src/useNavigationStart.ts +9 -0
- package/src/useRoute.ts +19 -0
- package/src/useRouteLinks.ts +22 -0
- package/src/useRouteState.ts +56 -0
- package/src/useTransientState.ts +191 -0
- package/src/useURL.ts +9 -0
- package/tsconfig.json +17 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { Route, State, URLState, compileURL, getNavigationOptions, isRouteEvent, isState, matchURL } from "stateshape";
|
|
2
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
3
|
+
import { jsx } from "react/jsx-runtime";
|
|
4
|
+
|
|
5
|
+
export * from "stateshape"
|
|
6
|
+
|
|
7
|
+
const RouteContext = createContext(new Route(null, { autoStart: false }));
|
|
8
|
+
|
|
9
|
+
function useLinkClick({ target, onClick }) {
|
|
10
|
+
let route = useContext(RouteContext);
|
|
11
|
+
return useCallback((event) => {
|
|
12
|
+
onClick?.(event);
|
|
13
|
+
if (!event.defaultPrevented && isRouteEvent(event) && (!target || target === "_self")) {
|
|
14
|
+
event.preventDefault();
|
|
15
|
+
route.navigate(getNavigationOptions(event.currentTarget));
|
|
16
|
+
}
|
|
17
|
+
}, [
|
|
18
|
+
route,
|
|
19
|
+
target,
|
|
20
|
+
onClick
|
|
21
|
+
]);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const A = ({ children, ...props }) => {
|
|
25
|
+
let handleClick = useLinkClick(props);
|
|
26
|
+
return /* @__PURE__ */ jsx("a", {
|
|
27
|
+
...props,
|
|
28
|
+
href: String(props.href),
|
|
29
|
+
onClick: handleClick,
|
|
30
|
+
children
|
|
31
|
+
});
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const Area = ({ alt, ...props }) => {
|
|
35
|
+
let handleClick = useLinkClick(props);
|
|
36
|
+
return /* @__PURE__ */ jsx("area", {
|
|
37
|
+
...props,
|
|
38
|
+
href: String(props.href),
|
|
39
|
+
onClick: handleClick,
|
|
40
|
+
alt
|
|
41
|
+
});
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* A component providing a Route instance to the nested components.
|
|
46
|
+
*/
|
|
47
|
+
const RouteProvider = ({ href, children }) => {
|
|
48
|
+
let route = useMemo(() => {
|
|
49
|
+
if (href instanceof Route) return href;
|
|
50
|
+
else if (href === void 0 || typeof href === "string") return new Route(href);
|
|
51
|
+
else throw new Error("URLProvider's 'href' of unsupported type");
|
|
52
|
+
}, [href]);
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
route.start();
|
|
55
|
+
return () => route.stop();
|
|
56
|
+
}, [route]);
|
|
57
|
+
return /* @__PURE__ */ jsx(RouteContext.Provider, {
|
|
58
|
+
value: route,
|
|
59
|
+
children
|
|
60
|
+
});
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
const TransientStateContext = createContext(/* @__PURE__ */ new Map());
|
|
64
|
+
|
|
65
|
+
const TransientStateProvider = ({ value, children }) => {
|
|
66
|
+
let defaultValueRef = useRef(null);
|
|
67
|
+
let stateMap = useMemo(() => {
|
|
68
|
+
if (value instanceof Map) return value;
|
|
69
|
+
if (typeof value === "object" && value !== null) return new Map(Object.entries(value).map(([key, value]) => [key, new State(value)]));
|
|
70
|
+
if (defaultValueRef.current === null) defaultValueRef.current = /* @__PURE__ */ new Map();
|
|
71
|
+
return defaultValueRef.current;
|
|
72
|
+
}, [value]);
|
|
73
|
+
return /* @__PURE__ */ jsx(TransientStateContext.Provider, {
|
|
74
|
+
value: stateMap,
|
|
75
|
+
children
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const URLContext = createContext(new URLState(null, { autoStart: false }));
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* A component providing a URL value to the nested components.
|
|
83
|
+
*/
|
|
84
|
+
const URLProvider = ({ href, children }) => {
|
|
85
|
+
let urlState = useMemo(() => {
|
|
86
|
+
if (href instanceof URLState) return href;
|
|
87
|
+
else if (href === void 0 || typeof href === "string") return new URLState(href);
|
|
88
|
+
else throw new Error("URLProvider's 'href' of unsupported type");
|
|
89
|
+
}, [href]);
|
|
90
|
+
useEffect(() => {
|
|
91
|
+
urlState.start();
|
|
92
|
+
return () => urlState.stop();
|
|
93
|
+
}, [urlState]);
|
|
94
|
+
return /* @__PURE__ */ jsx(URLContext.Provider, {
|
|
95
|
+
value: urlState,
|
|
96
|
+
children
|
|
97
|
+
});
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const defaultRenderCallback = (render) => render();
|
|
101
|
+
function useExternalState(state, callback = defaultRenderCallback, event) {
|
|
102
|
+
if (!isState(state)) throw new Error("'state' is not an instance of PortableState");
|
|
103
|
+
let [, setRevision] = useState(-1);
|
|
104
|
+
let setValue = useMemo(() => state.setValue.bind(state), [state]);
|
|
105
|
+
let initialStateRevision = useRef(state.revision);
|
|
106
|
+
let shouldUpdate = useRef(false);
|
|
107
|
+
useEffect(() => {
|
|
108
|
+
state.start();
|
|
109
|
+
if (callback === false) return;
|
|
110
|
+
shouldUpdate.current = true;
|
|
111
|
+
let render = () => {
|
|
112
|
+
if (shouldUpdate.current) setRevision(Math.random());
|
|
113
|
+
};
|
|
114
|
+
let resolvedCallback = typeof callback === "function" ? callback : defaultRenderCallback;
|
|
115
|
+
let unsubscribe = state.on(event ?? "update", (payload) => {
|
|
116
|
+
resolvedCallback(render, payload);
|
|
117
|
+
});
|
|
118
|
+
if (state.revision !== initialStateRevision.current) setRevision(Math.random());
|
|
119
|
+
return () => {
|
|
120
|
+
unsubscribe();
|
|
121
|
+
initialStateRevision.current = state.revision;
|
|
122
|
+
shouldUpdate.current = false;
|
|
123
|
+
};
|
|
124
|
+
}, [
|
|
125
|
+
state,
|
|
126
|
+
callback,
|
|
127
|
+
event
|
|
128
|
+
]);
|
|
129
|
+
return [state.getValue(), setValue];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function useNavigationComplete(callback) {
|
|
133
|
+
let route = useContext(RouteContext);
|
|
134
|
+
useEffect(() => route.on("navigationcomplete", callback), [route, callback]);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function useNavigationStart(callback) {
|
|
138
|
+
let route = useContext(RouteContext);
|
|
139
|
+
useEffect(() => route.on("navigationstart", callback), [route, callback]);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function useRoute(callback) {
|
|
143
|
+
let route = useContext(RouteContext);
|
|
144
|
+
useExternalState(route, callback, "navigation");
|
|
145
|
+
return useMemo(() => ({
|
|
146
|
+
route,
|
|
147
|
+
at: route.at.bind(route)
|
|
148
|
+
}), [route]);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Converts plain HTML links to SPA route links.
|
|
153
|
+
*
|
|
154
|
+
* @param containerRef - A React Ref pointing to a container element.
|
|
155
|
+
* @param elements - An optional selector, or an HTML element, or a
|
|
156
|
+
* collection thereof, specifying the links inside the container to
|
|
157
|
+
* be converted to SPA route links. Default: `"a, area"`.
|
|
158
|
+
*/
|
|
159
|
+
function useRouteLinks(containerRef, elements) {
|
|
160
|
+
let route = useContext(RouteContext);
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
return route.observe(() => containerRef.current, elements);
|
|
163
|
+
}, [
|
|
164
|
+
route,
|
|
165
|
+
elements,
|
|
166
|
+
containerRef
|
|
167
|
+
]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Reads and sets URL parameters in a way similar to React's `useState()`.
|
|
172
|
+
* This hooks returns `[state, setState]`, where `state` contains path
|
|
173
|
+
* placeholder parameters and query parameters, `{ params?, query? }`.
|
|
174
|
+
*
|
|
175
|
+
* Note that the path placeholders, `params`, are only available if the
|
|
176
|
+
* `url` parameter is an output of a typed URL builder (like the one
|
|
177
|
+
* produced with *url-shape*).
|
|
178
|
+
*/
|
|
179
|
+
function useRouteState(url, options) {
|
|
180
|
+
let { route } = useRoute();
|
|
181
|
+
let getState = useCallback((href) => {
|
|
182
|
+
let resolvedHref = String(href ?? route.href);
|
|
183
|
+
return matchURL(url === void 0 ? resolvedHref : url, resolvedHref);
|
|
184
|
+
}, [url, route]);
|
|
185
|
+
let setState = useCallback((update) => {
|
|
186
|
+
let urlData = typeof update === "function" ? update(getState()) : update;
|
|
187
|
+
route.navigate({
|
|
188
|
+
...options,
|
|
189
|
+
href: compileURL(url, urlData)
|
|
190
|
+
});
|
|
191
|
+
}, [
|
|
192
|
+
url,
|
|
193
|
+
route,
|
|
194
|
+
options,
|
|
195
|
+
getState
|
|
196
|
+
]);
|
|
197
|
+
return [getState(), setState];
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function createState(initial = true, pending = false, error) {
|
|
201
|
+
return {
|
|
202
|
+
initial,
|
|
203
|
+
pending,
|
|
204
|
+
error,
|
|
205
|
+
time: Date.now()
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
function useTransientState(state, action) {
|
|
209
|
+
let stateMap = useContext(TransientStateContext);
|
|
210
|
+
let stateRef = useRef(null);
|
|
211
|
+
let [stateItemInited, setStateItemInited] = useState(false);
|
|
212
|
+
let [actionState, setActionState] = useExternalState(useMemo(() => {
|
|
213
|
+
if (isState(state)) return state;
|
|
214
|
+
if (typeof state === "string") {
|
|
215
|
+
let stateItem = stateMap.get(state);
|
|
216
|
+
if (!stateItem) {
|
|
217
|
+
stateItem = new State(createState());
|
|
218
|
+
stateMap.set(state, stateItem);
|
|
219
|
+
if (!stateItemInited) setStateItemInited(true);
|
|
220
|
+
}
|
|
221
|
+
return stateItem;
|
|
222
|
+
}
|
|
223
|
+
if (!stateRef.current) stateRef.current = new State(createState());
|
|
224
|
+
return stateRef.current;
|
|
225
|
+
}, [
|
|
226
|
+
state,
|
|
227
|
+
stateMap,
|
|
228
|
+
stateItemInited
|
|
229
|
+
]));
|
|
230
|
+
let trackableAction = useCallback((...args) => {
|
|
231
|
+
if (!action) throw new Error("A trackable action is only available when the hook's 'action' parameter is set");
|
|
232
|
+
let options = args.at(-1);
|
|
233
|
+
let originalArgs = args.slice(0, -1);
|
|
234
|
+
let result;
|
|
235
|
+
try {
|
|
236
|
+
result = action(...originalArgs);
|
|
237
|
+
} catch (error) {
|
|
238
|
+
setActionState((prevState) => ({
|
|
239
|
+
...prevState,
|
|
240
|
+
...createState(false, false, error)
|
|
241
|
+
}));
|
|
242
|
+
if (options?.throws) throw error;
|
|
243
|
+
}
|
|
244
|
+
if (result instanceof Promise) {
|
|
245
|
+
let delayedTracking = null;
|
|
246
|
+
if (!options?.silent) {
|
|
247
|
+
let delay = options?.delay;
|
|
248
|
+
if (delay === void 0) setActionState((prevState) => ({
|
|
249
|
+
...prevState,
|
|
250
|
+
...createState(false, true)
|
|
251
|
+
}));
|
|
252
|
+
else delayedTracking = setTimeout(() => {
|
|
253
|
+
setActionState((prevState) => ({
|
|
254
|
+
...prevState,
|
|
255
|
+
...createState(false, true)
|
|
256
|
+
}));
|
|
257
|
+
delayedTracking = null;
|
|
258
|
+
}, delay);
|
|
259
|
+
}
|
|
260
|
+
return result.then((value) => {
|
|
261
|
+
if (delayedTracking !== null) clearTimeout(delayedTracking);
|
|
262
|
+
setActionState((prevState) => ({
|
|
263
|
+
...prevState,
|
|
264
|
+
...createState(false, false)
|
|
265
|
+
}));
|
|
266
|
+
return value;
|
|
267
|
+
}).catch((error) => {
|
|
268
|
+
if (delayedTracking !== null) clearTimeout(delayedTracking);
|
|
269
|
+
setActionState((prevState) => ({
|
|
270
|
+
...prevState,
|
|
271
|
+
...createState(false, false, error)
|
|
272
|
+
}));
|
|
273
|
+
if (options?.throws) throw error;
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
setActionState((prevState) => ({
|
|
277
|
+
...prevState,
|
|
278
|
+
...createState(false, false)
|
|
279
|
+
}));
|
|
280
|
+
return result;
|
|
281
|
+
}, [action, setActionState]);
|
|
282
|
+
return useMemo(() => {
|
|
283
|
+
let extendedActionState = {
|
|
284
|
+
...actionState,
|
|
285
|
+
update: setActionState
|
|
286
|
+
};
|
|
287
|
+
return action ? [extendedActionState, trackableAction] : [extendedActionState];
|
|
288
|
+
}, [
|
|
289
|
+
action,
|
|
290
|
+
trackableAction,
|
|
291
|
+
actionState,
|
|
292
|
+
setActionState
|
|
293
|
+
]);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function useURL(callback) {
|
|
297
|
+
return useExternalState(useContext(URLContext), callback, "navigation");
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
export { A, Area, RouteContext, RouteProvider, TransientStateContext, TransientStateProvider, URLContext, URLProvider, useExternalState, useLinkClick, useNavigationComplete, useNavigationStart, useRoute, useRouteLinks, useRouteState, useTransientState, useURL };
|
package/index.ts
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export * from "stateshape";
|
|
2
|
+
export * from "./src/A.tsx";
|
|
3
|
+
export * from "./src/Area.tsx";
|
|
4
|
+
export * from "./src/RouteContext.ts";
|
|
5
|
+
export * from "./src/RouteProvider.tsx";
|
|
6
|
+
export * from "./src/TransientStateContext.ts";
|
|
7
|
+
export * from "./src/TransientStateProvider.tsx";
|
|
8
|
+
export * from "./src/types/AProps.ts";
|
|
9
|
+
export * from "./src/types/AreaProps.ts";
|
|
10
|
+
export * from "./src/types/EnhanceHref.ts";
|
|
11
|
+
export * from "./src/types/LinkNavigationProps.ts";
|
|
12
|
+
export * from "./src/types/RenderCallback.ts";
|
|
13
|
+
export * from "./src/types/TransientState.ts";
|
|
14
|
+
export * from "./src/URLContext.ts";
|
|
15
|
+
export * from "./src/URLProvider.tsx";
|
|
16
|
+
export * from "./src/useExternalState.ts";
|
|
17
|
+
export * from "./src/useLinkClick.ts";
|
|
18
|
+
export * from "./src/useNavigationComplete.ts";
|
|
19
|
+
export * from "./src/useNavigationStart.ts";
|
|
20
|
+
export * from "./src/useRoute.ts";
|
|
21
|
+
export * from "./src/useRouteLinks.ts";
|
|
22
|
+
export * from "./src/useRouteState.ts";
|
|
23
|
+
export * from "./src/useTransientState.ts";
|
|
24
|
+
export * from "./src/useURL.ts";
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "react-stateshape",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Concise shared state management and routing in React apps",
|
|
5
|
+
"main": "./dist/index.cjs",
|
|
6
|
+
"module": "./dist/index.mjs",
|
|
7
|
+
"types": "./dist/index.d.ts",
|
|
8
|
+
"type": "module",
|
|
9
|
+
"scripts": {
|
|
10
|
+
"demo": "npx auxsrv tests/Shared_state_without_Context",
|
|
11
|
+
"preversion": "npx npm-run-all shape test",
|
|
12
|
+
"shape": "npx codeshape",
|
|
13
|
+
"t3": "npx auxsrv tests/Tic-tac-toe",
|
|
14
|
+
"test": "npx playwright test --project=chromium",
|
|
15
|
+
"typecheck": "npx codeshape --typecheck-only"
|
|
16
|
+
},
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/axtk/react-stateshape.git"
|
|
20
|
+
},
|
|
21
|
+
"keywords": [
|
|
22
|
+
"state",
|
|
23
|
+
"state management",
|
|
24
|
+
"react state management",
|
|
25
|
+
"shared state",
|
|
26
|
+
"global state",
|
|
27
|
+
"routing",
|
|
28
|
+
"react router"
|
|
29
|
+
],
|
|
30
|
+
"license": "MIT",
|
|
31
|
+
"author": "axtk",
|
|
32
|
+
"peerDependencies": {
|
|
33
|
+
"react": ">=16.8"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@playwright/test": "^1.58.2",
|
|
37
|
+
"@types/node": "^25.3.0",
|
|
38
|
+
"@types/react": "^19.2.14",
|
|
39
|
+
"@types/react-dom": "^19.2.3",
|
|
40
|
+
"auxsrv": "^0.3.1",
|
|
41
|
+
"immer": "^11.1.4",
|
|
42
|
+
"react-dom": "^19.2.4",
|
|
43
|
+
"url-shape": "^1.3.13",
|
|
44
|
+
"zod": "^4.3.6"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"stateshape": "^0.2.0"
|
|
48
|
+
}
|
|
49
|
+
}
|
package/src/A.tsx
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AProps } from "./types/AProps.ts";
|
|
2
|
+
import { useLinkClick } from "./useLinkClick.ts";
|
|
3
|
+
|
|
4
|
+
export const A = ({ children, ...props }: AProps) => {
|
|
5
|
+
let handleClick = useLinkClick(props);
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<a {...props} href={String(props.href)} onClick={handleClick}>
|
|
9
|
+
{children}
|
|
10
|
+
</a>
|
|
11
|
+
);
|
|
12
|
+
};
|
package/src/Area.tsx
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { AreaProps } from "./types/AreaProps.ts";
|
|
2
|
+
import { useLinkClick } from "./useLinkClick.ts";
|
|
3
|
+
|
|
4
|
+
export const Area = ({ alt, ...props }: AreaProps) => {
|
|
5
|
+
let handleClick = useLinkClick(props);
|
|
6
|
+
|
|
7
|
+
return (
|
|
8
|
+
<area
|
|
9
|
+
{...props}
|
|
10
|
+
href={String(props.href)}
|
|
11
|
+
onClick={handleClick}
|
|
12
|
+
alt={alt}
|
|
13
|
+
/>
|
|
14
|
+
);
|
|
15
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo } from "react";
|
|
2
|
+
import { Route } from "stateshape";
|
|
3
|
+
import { RouteContext } from "./RouteContext.ts";
|
|
4
|
+
|
|
5
|
+
export type RouteProviderProps = {
|
|
6
|
+
href?: string | Route | undefined;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A component providing a Route instance to the nested components.
|
|
12
|
+
*/
|
|
13
|
+
export const RouteProvider = ({ href, children }: RouteProviderProps) => {
|
|
14
|
+
let route = useMemo(() => {
|
|
15
|
+
if (href instanceof Route) return href;
|
|
16
|
+
else if (href === undefined || typeof href === "string")
|
|
17
|
+
return new Route(href);
|
|
18
|
+
else throw new Error("URLProvider's 'href' of unsupported type");
|
|
19
|
+
}, [href]);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
route.start();
|
|
23
|
+
|
|
24
|
+
return () => route.stop();
|
|
25
|
+
}, [route]);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<RouteContext.Provider value={route}>{children}</RouteContext.Provider>
|
|
29
|
+
);
|
|
30
|
+
};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type ReactNode, useMemo, useRef } from "react";
|
|
2
|
+
import { State } from "stateshape";
|
|
3
|
+
import { TransientStateContext } from "./TransientStateContext.ts";
|
|
4
|
+
import type { TransientState } from "./types/TransientState.ts";
|
|
5
|
+
|
|
6
|
+
export type TransientStateProviderProps = {
|
|
7
|
+
value?:
|
|
8
|
+
| Record<string, TransientState>
|
|
9
|
+
| Map<string, State<TransientState>>
|
|
10
|
+
| null
|
|
11
|
+
| undefined;
|
|
12
|
+
children?: ReactNode;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
export const TransientStateProvider = ({
|
|
16
|
+
value,
|
|
17
|
+
children,
|
|
18
|
+
}: TransientStateProviderProps) => {
|
|
19
|
+
let defaultValueRef = useRef<Map<string, State<TransientState>> | null>(null);
|
|
20
|
+
|
|
21
|
+
let stateMap = useMemo(() => {
|
|
22
|
+
if (value instanceof Map) return value;
|
|
23
|
+
|
|
24
|
+
if (typeof value === "object" && value !== null)
|
|
25
|
+
return new Map(
|
|
26
|
+
Object.entries(value).map(([key, value]) => [key, new State(value)]),
|
|
27
|
+
);
|
|
28
|
+
|
|
29
|
+
if (defaultValueRef.current === null)
|
|
30
|
+
defaultValueRef.current = new Map<string, State<TransientState>>();
|
|
31
|
+
|
|
32
|
+
return defaultValueRef.current;
|
|
33
|
+
}, [value]);
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
<TransientStateContext.Provider value={stateMap}>
|
|
37
|
+
{children}
|
|
38
|
+
</TransientStateContext.Provider>
|
|
39
|
+
);
|
|
40
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { type ReactNode, useEffect, useMemo } from "react";
|
|
2
|
+
import { URLState } from "stateshape";
|
|
3
|
+
import { URLContext } from "./URLContext.ts";
|
|
4
|
+
|
|
5
|
+
export type URLProviderProps = {
|
|
6
|
+
href?: string | URLState | undefined;
|
|
7
|
+
children?: ReactNode;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A component providing a URL value to the nested components.
|
|
12
|
+
*/
|
|
13
|
+
export const URLProvider = ({ href, children }: URLProviderProps) => {
|
|
14
|
+
let urlState = useMemo(() => {
|
|
15
|
+
if (href instanceof URLState) return href;
|
|
16
|
+
else if (href === undefined || typeof href === "string")
|
|
17
|
+
return new URLState(href);
|
|
18
|
+
else throw new Error("URLProvider's 'href' of unsupported type");
|
|
19
|
+
}, [href]);
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
urlState.start();
|
|
23
|
+
|
|
24
|
+
return () => urlState.stop();
|
|
25
|
+
}, [urlState]);
|
|
26
|
+
|
|
27
|
+
return <URLContext.Provider value={urlState}>{children}</URLContext.Provider>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AnchorHTMLAttributes } from "react";
|
|
2
|
+
import type { EnhanceHref } from "./EnhanceHref.ts";
|
|
3
|
+
import type { LinkNavigationProps } from "./LinkNavigationProps.ts";
|
|
4
|
+
|
|
5
|
+
export type AProps = EnhanceHref<AnchorHTMLAttributes<HTMLAnchorElement>> &
|
|
6
|
+
LinkNavigationProps;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { AreaHTMLAttributes } from "react";
|
|
2
|
+
import type { EnhanceHref } from "./EnhanceHref.ts";
|
|
3
|
+
import type { LinkNavigationProps } from "./LinkNavigationProps.ts";
|
|
4
|
+
|
|
5
|
+
export type AreaProps = EnhanceHref<AreaHTMLAttributes<HTMLAreaElement>> &
|
|
6
|
+
LinkNavigationProps;
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { NavigationOptions } from "stateshape";
|
|
2
|
+
|
|
3
|
+
export type LinkNavigationProps = {
|
|
4
|
+
"data-spa"?: NavigationOptions["spa"];
|
|
5
|
+
"data-history"?: NavigationOptions["history"];
|
|
6
|
+
"data-scroll"?: NavigationOptions["scroll"];
|
|
7
|
+
"data-id"?: NavigationOptions["id"];
|
|
8
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { isState, type State, type StatePayloadMap } from "stateshape";
|
|
3
|
+
import type { RenderCallback } from "./types/RenderCallback.ts";
|
|
4
|
+
|
|
5
|
+
export type SetExternalStateValue<
|
|
6
|
+
T,
|
|
7
|
+
P extends StatePayloadMap<T> = StatePayloadMap<T>,
|
|
8
|
+
> = State<T, P>["setValue"];
|
|
9
|
+
|
|
10
|
+
const defaultRenderCallback = (render: () => void) => render();
|
|
11
|
+
|
|
12
|
+
export function useExternalState<T, P extends StatePayloadMap<T>>(
|
|
13
|
+
state: State<T, P>,
|
|
14
|
+
callback?: RenderCallback<P["update"]> | boolean,
|
|
15
|
+
): [T, SetExternalStateValue<T, P>];
|
|
16
|
+
|
|
17
|
+
export function useExternalState<
|
|
18
|
+
T,
|
|
19
|
+
P extends StatePayloadMap<T>,
|
|
20
|
+
E extends keyof P,
|
|
21
|
+
>(
|
|
22
|
+
state: State<T, P>,
|
|
23
|
+
callback?: RenderCallback<P[E]> | boolean,
|
|
24
|
+
event?: E,
|
|
25
|
+
): [T, SetExternalStateValue<T, P>];
|
|
26
|
+
|
|
27
|
+
export function useExternalState<
|
|
28
|
+
T,
|
|
29
|
+
P extends StatePayloadMap<T>,
|
|
30
|
+
E extends string,
|
|
31
|
+
>(
|
|
32
|
+
state: State<T, P>,
|
|
33
|
+
callback: RenderCallback<P[E]> | boolean = defaultRenderCallback,
|
|
34
|
+
event?: E,
|
|
35
|
+
): [T, SetExternalStateValue<T, P>] {
|
|
36
|
+
if (!isState<T, P>(state))
|
|
37
|
+
throw new Error("'state' is not an instance of PortableState");
|
|
38
|
+
|
|
39
|
+
let [, setRevision] = useState(-1);
|
|
40
|
+
|
|
41
|
+
let setValue = useMemo(() => state.setValue.bind(state), [state]);
|
|
42
|
+
let initialStateRevision = useRef(state.revision);
|
|
43
|
+
let shouldUpdate = useRef(false);
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
// Start the state if it's not started yet
|
|
47
|
+
state.start();
|
|
48
|
+
|
|
49
|
+
if (callback === false) return;
|
|
50
|
+
|
|
51
|
+
shouldUpdate.current = true;
|
|
52
|
+
|
|
53
|
+
let render = () => {
|
|
54
|
+
// Use `setRevision()` as long as the component is mounted
|
|
55
|
+
if (shouldUpdate.current) setRevision(Math.random());
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
let resolvedCallback =
|
|
59
|
+
typeof callback === "function" ? callback : defaultRenderCallback;
|
|
60
|
+
|
|
61
|
+
let unsubscribe = state.on(event ?? "update", (payload) => {
|
|
62
|
+
resolvedCallback(render, payload as P[E]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
if (state.revision !== initialStateRevision.current)
|
|
66
|
+
setRevision(Math.random());
|
|
67
|
+
|
|
68
|
+
return () => {
|
|
69
|
+
unsubscribe();
|
|
70
|
+
initialStateRevision.current = state.revision;
|
|
71
|
+
shouldUpdate.current = false;
|
|
72
|
+
};
|
|
73
|
+
}, [state, callback, event]);
|
|
74
|
+
|
|
75
|
+
return [state.getValue(), setValue];
|
|
76
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type MouseEvent as ReactMouseEvent,
|
|
3
|
+
useCallback,
|
|
4
|
+
useContext,
|
|
5
|
+
} from "react";
|
|
6
|
+
import { getNavigationOptions, isRouteEvent } from "stateshape";
|
|
7
|
+
import { RouteContext } from "./RouteContext.ts";
|
|
8
|
+
import type { AProps } from "./types/AProps.ts";
|
|
9
|
+
import type { AreaProps } from "./types/AreaProps.ts";
|
|
10
|
+
|
|
11
|
+
export function useLinkClick({ target, onClick }: AProps | AreaProps) {
|
|
12
|
+
let route = useContext(RouteContext);
|
|
13
|
+
|
|
14
|
+
return useCallback(
|
|
15
|
+
(event: ReactMouseEvent<HTMLAnchorElement & HTMLAreaElement>) => {
|
|
16
|
+
onClick?.(event);
|
|
17
|
+
|
|
18
|
+
if (
|
|
19
|
+
!event.defaultPrevented &&
|
|
20
|
+
isRouteEvent(event) &&
|
|
21
|
+
(!target || target === "_self")
|
|
22
|
+
) {
|
|
23
|
+
event.preventDefault();
|
|
24
|
+
route.navigate(getNavigationOptions(event.currentTarget));
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
[route, target, onClick],
|
|
28
|
+
);
|
|
29
|
+
}
|