@equinor/fusion-framework-module-navigation 6.0.1 → 7.0.0-next.2

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.
Files changed (159) hide show
  1. package/CHANGELOG.md +83 -5
  2. package/README.md +694 -0
  3. package/dist/esm/NavigationConfigurator.interface.js +2 -0
  4. package/dist/esm/NavigationConfigurator.interface.js.map +1 -0
  5. package/dist/esm/NavigationConfigurator.js +144 -0
  6. package/dist/esm/NavigationConfigurator.js.map +1 -0
  7. package/dist/esm/NavigationProvider.interface.js +2 -0
  8. package/dist/esm/NavigationProvider.interface.js.map +1 -0
  9. package/dist/esm/NavigationProvider.js +256 -0
  10. package/dist/esm/NavigationProvider.js.map +1 -0
  11. package/dist/esm/enable-navigation.js +52 -0
  12. package/dist/esm/enable-navigation.js.map +1 -0
  13. package/dist/esm/events.js +23 -0
  14. package/dist/esm/events.js.map +1 -0
  15. package/dist/esm/index.js +6 -5
  16. package/dist/esm/index.js.map +1 -1
  17. package/dist/esm/lib/BaseHistory.js +189 -0
  18. package/dist/esm/lib/BaseHistory.js.map +1 -0
  19. package/dist/esm/lib/BrowserHistory.js +91 -0
  20. package/dist/esm/lib/BrowserHistory.js.map +1 -0
  21. package/dist/esm/lib/BrowserHistoryHashStack.js +53 -0
  22. package/dist/esm/lib/BrowserHistoryHashStack.js.map +1 -0
  23. package/dist/esm/lib/BrowserHistoryStack.js +77 -0
  24. package/dist/esm/lib/BrowserHistoryStack.js.map +1 -0
  25. package/dist/esm/lib/MemoryHistory.js +47 -0
  26. package/dist/esm/lib/MemoryHistory.js.map +1 -0
  27. package/dist/esm/lib/MemoryStack.js +98 -0
  28. package/dist/esm/lib/MemoryStack.js.map +1 -0
  29. package/dist/esm/lib/create-history.js +48 -0
  30. package/dist/esm/lib/create-history.js.map +1 -0
  31. package/dist/esm/lib/index.js +8 -1
  32. package/dist/esm/lib/index.js.map +1 -1
  33. package/dist/esm/lib/state/history.actions.js +71 -0
  34. package/dist/esm/lib/state/history.actions.js.map +1 -0
  35. package/dist/esm/lib/state/history.flows.js +212 -0
  36. package/dist/esm/lib/state/history.flows.js.map +1 -0
  37. package/dist/esm/lib/state/history.reducer.js +59 -0
  38. package/dist/esm/lib/state/history.reducer.js.map +1 -0
  39. package/dist/esm/lib/state/history.state.js +37 -0
  40. package/dist/esm/lib/state/history.state.js.map +1 -0
  41. package/dist/esm/lib/state/index.js +4 -0
  42. package/dist/esm/lib/state/index.js.map +1 -0
  43. package/dist/esm/lib/types.js +25 -0
  44. package/dist/esm/lib/types.js.map +1 -0
  45. package/dist/esm/lib/utils/encode-trailing-whitespace.js +19 -0
  46. package/dist/esm/lib/utils/encode-trailing-whitespace.js.map +1 -0
  47. package/dist/esm/lib/utils/has-protocol.js +22 -0
  48. package/dist/esm/lib/utils/has-protocol.js.map +1 -0
  49. package/dist/esm/lib/utils/index.js +6 -0
  50. package/dist/esm/lib/utils/index.js.map +1 -0
  51. package/dist/esm/lib/utils/path-to-string.js +23 -0
  52. package/dist/esm/lib/utils/path-to-string.js.map +1 -0
  53. package/dist/esm/lib/utils/path-to-url.js +47 -0
  54. package/dist/esm/lib/utils/path-to-url.js.map +1 -0
  55. package/dist/esm/lib/utils/resolve-browser-location.js +53 -0
  56. package/dist/esm/lib/utils/resolve-browser-location.js.map +1 -0
  57. package/dist/esm/lib/utils/resolve-path.js +21 -0
  58. package/dist/esm/lib/utils/resolve-path.js.map +1 -0
  59. package/dist/esm/module.js +34 -25
  60. package/dist/esm/module.js.map +1 -1
  61. package/dist/esm/version.js +1 -1
  62. package/dist/esm/version.js.map +1 -1
  63. package/dist/tsconfig.tsbuildinfo +1 -1
  64. package/dist/types/NavigationConfigurator.d.ts +60 -0
  65. package/dist/types/NavigationConfigurator.interface.d.ts +17 -0
  66. package/dist/types/NavigationProvider.d.ts +112 -0
  67. package/dist/types/NavigationProvider.interface.d.ts +75 -0
  68. package/dist/types/enable-navigation.d.ts +40 -0
  69. package/dist/types/events.d.ts +48 -0
  70. package/dist/types/index.d.ts +13 -6
  71. package/dist/types/lib/BaseHistory.d.ts +122 -0
  72. package/dist/types/lib/BrowserHistory.d.ts +53 -0
  73. package/dist/types/lib/BrowserHistoryHashStack.d.ts +41 -0
  74. package/dist/types/lib/BrowserHistoryStack.d.ts +51 -0
  75. package/dist/types/lib/MemoryHistory.d.ts +27 -0
  76. package/dist/types/lib/MemoryStack.d.ts +57 -0
  77. package/dist/types/lib/create-history.d.ts +35 -0
  78. package/dist/types/lib/index.d.ts +7 -1
  79. package/dist/types/lib/state/history.actions.d.ts +61 -0
  80. package/dist/types/lib/state/history.flows.d.ts +102 -0
  81. package/dist/types/lib/state/history.reducer.d.ts +85 -0
  82. package/dist/types/lib/state/history.state.d.ts +27 -0
  83. package/dist/types/lib/state/index.d.ts +3 -0
  84. package/dist/types/lib/types.d.ts +145 -0
  85. package/dist/types/lib/utils/encode-trailing-whitespace.d.ts +16 -0
  86. package/dist/types/lib/utils/has-protocol.d.ts +19 -0
  87. package/dist/types/lib/utils/index.d.ts +5 -0
  88. package/dist/types/lib/utils/path-to-string.d.ts +17 -0
  89. package/dist/types/lib/utils/path-to-url.d.ts +32 -0
  90. package/dist/types/lib/utils/resolve-browser-location.d.ts +42 -0
  91. package/dist/types/lib/utils/resolve-path.d.ts +17 -0
  92. package/dist/types/module.d.ts +33 -5
  93. package/dist/types/version.d.ts +1 -1
  94. package/package.json +25 -7
  95. package/src/NavigationConfigurator.interface.ts +18 -0
  96. package/src/NavigationConfigurator.ts +184 -0
  97. package/src/NavigationProvider.interface.ts +86 -0
  98. package/src/NavigationProvider.ts +313 -0
  99. package/src/__tests__/BrowserHistory.test.ts +151 -0
  100. package/src/__tests__/HashHistory.test.ts +121 -0
  101. package/src/__tests__/MemoryHistory.test.ts +185 -0
  102. package/src/__tests__/NavigationProvider.test.ts +60 -0
  103. package/src/__tests__/setup.ts +8 -0
  104. package/src/enable-navigation.ts +59 -0
  105. package/src/events.ts +65 -0
  106. package/src/index.ts +27 -6
  107. package/src/lib/BaseHistory.ts +229 -0
  108. package/src/lib/BrowserHistory.ts +123 -0
  109. package/src/lib/BrowserHistoryHashStack.ts +56 -0
  110. package/src/lib/BrowserHistoryStack.ts +82 -0
  111. package/src/lib/MemoryHistory.ts +66 -0
  112. package/src/lib/MemoryStack.ts +106 -0
  113. package/src/lib/create-history.ts +63 -0
  114. package/src/lib/index.ts +22 -1
  115. package/src/lib/state/history.actions.ts +108 -0
  116. package/src/lib/state/history.flows.ts +279 -0
  117. package/src/lib/state/history.reducer.ts +70 -0
  118. package/src/lib/state/history.state.ts +62 -0
  119. package/src/lib/state/index.ts +3 -0
  120. package/src/lib/types.ts +159 -0
  121. package/src/lib/utils/encode-trailing-whitespace.ts +18 -0
  122. package/src/lib/utils/has-protocol.ts +21 -0
  123. package/src/lib/utils/index.ts +5 -0
  124. package/src/lib/utils/path-to-string.ts +24 -0
  125. package/src/lib/utils/path-to-url.ts +52 -0
  126. package/src/lib/utils/resolve-browser-location.ts +60 -0
  127. package/src/lib/utils/resolve-path.ts +22 -0
  128. package/src/module.ts +53 -37
  129. package/src/version.ts +1 -1
  130. package/tsconfig.json +10 -1
  131. package/vitest.config.ts +14 -0
  132. package/dist/esm/configurator.js +0 -5
  133. package/dist/esm/configurator.js.map +0 -1
  134. package/dist/esm/createHistory.js +0 -18
  135. package/dist/esm/createHistory.js.map +0 -1
  136. package/dist/esm/lib/provider/INavigationProvider.js +0 -2
  137. package/dist/esm/lib/provider/INavigationProvider.js.map +0 -1
  138. package/dist/esm/lib/provider/NavigationProvider.js +0 -82
  139. package/dist/esm/lib/provider/NavigationProvider.js.map +0 -1
  140. package/dist/esm/lib/provider/index.js +0 -2
  141. package/dist/esm/lib/provider/index.js.map +0 -1
  142. package/dist/esm/navigator.js +0 -158
  143. package/dist/esm/navigator.js.map +0 -1
  144. package/dist/esm/types.js +0 -2
  145. package/dist/esm/types.js.map +0 -1
  146. package/dist/types/configurator.d.ts +0 -9
  147. package/dist/types/createHistory.d.ts +0 -4
  148. package/dist/types/lib/provider/INavigationProvider.d.ts +0 -38
  149. package/dist/types/lib/provider/NavigationProvider.d.ts +0 -22
  150. package/dist/types/lib/provider/index.d.ts +0 -2
  151. package/dist/types/navigator.d.ts +0 -105
  152. package/dist/types/types.d.ts +0 -3
  153. package/src/configurator.ts +0 -11
  154. package/src/createHistory.ts +0 -21
  155. package/src/lib/provider/INavigationProvider.ts +0 -46
  156. package/src/lib/provider/NavigationProvider.ts +0 -117
  157. package/src/lib/provider/index.ts +0 -2
  158. package/src/navigator.ts +0 -204
  159. package/src/types.ts +0 -4
@@ -0,0 +1,279 @@
1
+ import { Observable, of, merge } from 'rxjs';
2
+ import { concatMap, map, withLatestFrom } from 'rxjs/operators';
3
+ import type { Flow } from '@equinor/fusion-observable';
4
+ import { filterAction } from '@equinor/fusion-observable/operators';
5
+ import { actions, type Actions } from './history.actions';
6
+ import { resolvePath } from '../utils';
7
+ import { type Location, type LocationState, type HistoryStack, Action } from '../types';
8
+
9
+ /**
10
+ * History flow for processing navigation actions.
11
+ */
12
+ export type HistoryFlow = Flow<Actions, LocationState>;
13
+
14
+ /**
15
+ * Factory function that creates a history flow from a stack.
16
+ */
17
+ export type HistoryFlowCreator = (stack: HistoryStack) => HistoryFlow;
18
+
19
+ const compareLocation = (a: Location, b: Location) => {
20
+ return (
21
+ a.hash === b.hash &&
22
+ a.search === b.search &&
23
+ a.pathname === b.pathname &&
24
+ JSON.stringify(a.state) === JSON.stringify(b.state)
25
+ );
26
+ };
27
+
28
+ /**
29
+ * Flow creator for handling navigate actions.
30
+ *
31
+ * Execution flow:
32
+ * 1. Filters for navigate actions
33
+ * 2. Resolves the target path from the action payload
34
+ * 3. Creates a location object with the resolved path, key (from action meta), and optional state
35
+ * 4. Checks if the new location is the same as current to prevent navigation loops
36
+ * 5. Updates the stack based on replace option (replace updates current, otherwise pushes new entry)
37
+ * 6. Returns success action with the new location and appropriate action type (PUSH/REPLACE)
38
+ */
39
+ export const navigate: HistoryFlowCreator =
40
+ (stack: HistoryStack): HistoryFlow =>
41
+ (action$: Observable<Actions>) => {
42
+ const { navigate } = actions;
43
+ return action$.pipe(
44
+ filterAction(navigate.type),
45
+ map((action) => {
46
+ const { payload, meta } = action;
47
+ const path = resolvePath(payload.to);
48
+ const key = meta.key;
49
+ const { replace, state } = payload.options;
50
+ const nextLocation = { ...path, key, state } satisfies Location;
51
+ // prevent death loop navigation
52
+ if (compareLocation(nextLocation, stack.current)) {
53
+ return actions.abortNavigate('Location is the same as the current location');
54
+ }
55
+ if (replace) {
56
+ stack.replace(nextLocation);
57
+ } else {
58
+ stack.push(nextLocation);
59
+ }
60
+ return navigate.success({
61
+ delta: 0,
62
+ action: replace ? Action.Replace : Action.Push,
63
+ location: nextLocation,
64
+ });
65
+ }),
66
+ );
67
+ };
68
+
69
+ /**
70
+ * Flow creator for handling go (back/forward) actions.
71
+ *
72
+ * Execution flow:
73
+ * 1. Filters for go actions
74
+ * 2. Combines with current state to get history entries
75
+ * 3. Moves the stack index by the specified delta (negative for back, positive for forward)
76
+ * 4. Returns success action with POP action type and the new current location
77
+ */
78
+ export const go: HistoryFlowCreator =
79
+ (stack: HistoryStack): HistoryFlow =>
80
+ (action$: Observable<Actions>, state$: Observable<LocationState>) => {
81
+ const { go } = actions;
82
+ return action$.pipe(
83
+ filterAction(go.type),
84
+ withLatestFrom(state$),
85
+ map(([action, state]) => {
86
+ const { delta } = action.payload;
87
+ stack.go(delta, state);
88
+ return go.success({
89
+ delta: 0,
90
+ action: Action.Pop,
91
+ location: stack.current,
92
+ });
93
+ }),
94
+ );
95
+ };
96
+
97
+ /**
98
+ * Flow creator for handling pop (browser back/forward) actions.
99
+ *
100
+ * Execution flow:
101
+ * 1. Filters for pop actions
102
+ * 2. Uses the location from the action payload or falls back to stack.current
103
+ * 3. Emits a success action with POP action type and the current location
104
+ * 4. Completes the observable immediately (synchronous operation)
105
+ */
106
+ export const pop: HistoryFlowCreator =
107
+ (stack: HistoryStack): HistoryFlow =>
108
+ (action$: Observable<Actions>) => {
109
+ const { pop } = actions;
110
+ return action$.pipe(
111
+ filterAction(pop.type),
112
+ concatMap(
113
+ (action) =>
114
+ new Observable<Actions>((subscriber) => {
115
+ const currentLocation = action.payload.update?.location ?? stack.current;
116
+ // todo - we might need to check if the current location is in the history stack
117
+ // for now we will just use the current location
118
+ subscriber.next(
119
+ pop.success({
120
+ delta: 0,
121
+ action: Action.Pop,
122
+ location: currentLocation,
123
+ }),
124
+ );
125
+ subscriber.complete();
126
+ }),
127
+ ),
128
+ );
129
+ };
130
+
131
+ /**
132
+ * Flow creator for checking navigation blockers before processing actions.
133
+ *
134
+ * Execution flow:
135
+ * 1. Filters for navigation actions (navigate, go, pop, validateLocation)
136
+ * 2. Determines the action type (PUSH/REPLACE for navigate, POP for others)
137
+ * 3. Combines with current state to check for active blockers
138
+ * 4. If no blockers exist, passes the action through immediately
139
+ * 5. If blockers exist:
140
+ * - Creates a transition object with action, location, and retry callback
141
+ * - Calls each blocker asynchronously (wrapped in Promise to prevent blocking)
142
+ * - Blockers can call retry() to allow navigation or prevent it by not calling retry()
143
+ * - Waits for all blockers to complete before continuing
144
+ * 6. Returns the original action if navigation is allowed, or completes without emitting if blocked
145
+ */
146
+ export const checkBlockers: HistoryFlowCreator =
147
+ (stack: HistoryStack): HistoryFlow =>
148
+ (action$: Observable<Actions>, state$: Observable<LocationState>) => {
149
+ const { pop, navigate, go, validateLocation } = actions;
150
+ return action$.pipe(
151
+ filterAction(navigate.type, pop.type, go.type, validateLocation.type),
152
+ map((action) => {
153
+ switch (action.type) {
154
+ case navigate.type:
155
+ return {
156
+ replace: action.payload.options.replace,
157
+ action: action,
158
+ };
159
+ case go.type:
160
+ case pop.type:
161
+ case validateLocation.type:
162
+ return {
163
+ type: 'POP' as Action,
164
+ action: action,
165
+ };
166
+ }
167
+ }),
168
+ withLatestFrom(state$),
169
+ concatMap(([{ action, replace }, state]) => {
170
+ if (!state.blockers.length) {
171
+ return of(action);
172
+ }
173
+ return new Observable<Actions>((subscriber) => {
174
+ const location = stack.current;
175
+ const blockers = state.blockers.map((blocker) =>
176
+ Promise.resolve(
177
+ blocker({
178
+ delta: 0,
179
+ action: replace ? Action.Replace : Action.Push,
180
+ location,
181
+ retry: () => {
182
+ subscriber.next(action);
183
+ },
184
+ }),
185
+ ),
186
+ );
187
+ Promise.allSettled(blockers).then(() => {
188
+ subscriber.complete();
189
+ });
190
+ });
191
+ }),
192
+ );
193
+ };
194
+
195
+ /**
196
+ * Flow creator for validating the current location against history state.
197
+ *
198
+ * Execution flow:
199
+ * 1. Filters for validateLocation actions
200
+ * 2. Combines with current state to access history entries
201
+ * 3. Gets the current location from the stack
202
+ * 4. Searches history for an entry matching the current location's key
203
+ * 5. Validates that:
204
+ * - The location key exists in history (returns failure if not found)
205
+ * - The location state matches the history entry state (returns failure if mismatch)
206
+ * 6. Returns success action with current location if validation passes, failure otherwise
207
+ */
208
+ export const validateCurrentLocation: HistoryFlowCreator =
209
+ (stack: HistoryStack): HistoryFlow =>
210
+ (action$: Observable<Actions>, state$: Observable<LocationState>) => {
211
+ const { validateLocation } = actions;
212
+ return action$.pipe(
213
+ filterAction(validateLocation.type),
214
+ withLatestFrom(state$),
215
+ map(([, state]) => {
216
+ const currentLocation = stack.current;
217
+ const record = state.history.find(({ location }) => location?.key === currentLocation?.key);
218
+ if (!record) {
219
+ return validateLocation.failure(new Error('Stack state not found'));
220
+ }
221
+ if (record?.location?.state !== currentLocation?.state) {
222
+ return validateLocation.failure(new Error('Stack state mismatch'));
223
+ }
224
+ return validateLocation.success({
225
+ delta: 0,
226
+ action: Action.Pop,
227
+ location: currentLocation,
228
+ });
229
+ }),
230
+ );
231
+ };
232
+
233
+ /**
234
+ * Creates a combined history flow from multiple flow creators.
235
+ *
236
+ * Execution flow:
237
+ * 1. Creates a preprocessing step that applies blocker checking (unless skipped)
238
+ * 2. Initializes each flow creator with the stack to create individual flows
239
+ * 3. Merges all flows so they process actions in parallel
240
+ * 4. Returns a combined flow that:
241
+ * - First applies preprocessing (blocker checking)
242
+ * - Then processes actions through all merged flows
243
+ * - Emits results from any flow that matches the action
244
+ *
245
+ * @param flowCreators - Array of flow creators to combine
246
+ * @param options - Optional configuration options
247
+ * @param options.skipBlockCheck - If true, skip blocker checking
248
+ * @returns A combined history flow creator
249
+ */
250
+ export const createFlow = (
251
+ flowCreators: HistoryFlowCreator[],
252
+ options?: { skipBlockCheck?: boolean },
253
+ ): HistoryFlowCreator => {
254
+ return (stack: HistoryStack): HistoryFlow => {
255
+ const preProcessActions = options?.skipBlockCheck
256
+ ? (action$: Observable<Actions>) => action$
257
+ : checkBlockers(stack);
258
+
259
+ const flows: HistoryFlow = (
260
+ action$: Observable<Actions>,
261
+ state$: Observable<LocationState>,
262
+ ) => {
263
+ return merge(
264
+ ...flowCreators
265
+ .map((initializer) => initializer(stack))
266
+ .map((flow) => flow(action$, state$)),
267
+ );
268
+ };
269
+
270
+ return (action$: Observable<Actions>, state$: Observable<LocationState>) => {
271
+ return preProcessActions(action$, state$).pipe((source$) => flows(source$, state$));
272
+ };
273
+ };
274
+ };
275
+
276
+ /**
277
+ * Collection of flow creators for history state management.
278
+ */
279
+ export const flowCreators = { navigate, go, pop, validateCurrentLocation };
@@ -0,0 +1,70 @@
1
+ import { createReducer } from '@equinor/fusion-observable';
2
+ import type { LocationState, NavigationUpdate } from '../types';
3
+ import { actions, type Actions } from './history.actions';
4
+ import { isSuccessAction } from '@equinor/fusion-observable/actions';
5
+
6
+ /**
7
+ * Finds the index of the current location in the history array.
8
+ * Used to determine where to insert/replace entries for REPLACE actions.
9
+ */
10
+ const findIndex = (state: LocationState) => {
11
+ return state.history.findIndex((update) => update.location.key === state.current.location.key);
12
+ };
13
+
14
+ /**
15
+ * Creates a reducer for history state management.
16
+ *
17
+ * @param initial - Initial navigation update or function that returns initial state
18
+ * @param options - Optional configuration options
19
+ * @param options.maxHistory - Maximum number of history entries to keep (default: 100)
20
+ * @returns A reducer for history state
21
+ */
22
+ export const createHistoryReducer = (
23
+ initial: NavigationUpdate | (() => LocationState),
24
+ options?: { maxHistory?: number },
25
+ ) => {
26
+ const maxHistory = options?.maxHistory ?? 100;
27
+
28
+ const initialState: LocationState =
29
+ initial instanceof Function
30
+ ? initial()
31
+ : ({
32
+ current: initial,
33
+ history: [initial],
34
+ blockers: [],
35
+ } satisfies LocationState);
36
+
37
+ return createReducer<LocationState, Actions>(initialState, (builder) => {
38
+ // Handle navigate.success actions to update history array
39
+ builder.addCase(actions.navigate.success, (state, action) => {
40
+ const { update } = action.payload;
41
+ // For REPLACE actions, remove all entries after the current location (the tail)
42
+ // This ensures REPLACE doesn't add to history length when at the end,
43
+ // and removes any forward history when not at the end
44
+ if (update.action === 'REPLACE') {
45
+ const currentIndex = findIndex(state);
46
+ if (currentIndex !== -1) {
47
+ // Remove all entries from current position onwards
48
+ state.history.splice(currentIndex, state.history.length - currentIndex);
49
+ }
50
+ }
51
+ state.history.push(update);
52
+ });
53
+ // Update current location for all successful actions (navigate, go, pop, etc.)
54
+ builder.addMatcher(isSuccessAction, (state, action) => {
55
+ const { update } = action.payload;
56
+ state.current = update;
57
+ });
58
+ // Limit history size to prevent unbounded growth
59
+ // This matcher always runs (returns true) to check history length after every action
60
+ builder.addMatcher(
61
+ () => true,
62
+ (state) => {
63
+ if (state.history.length > maxHistory) {
64
+ // Keep only the most recent entries, removing oldest ones
65
+ state.history = state.history.slice(-maxHistory);
66
+ }
67
+ },
68
+ );
69
+ });
70
+ };
@@ -0,0 +1,62 @@
1
+ import {
2
+ createState,
3
+ type FlowState,
4
+ type ReducerWithInitialState,
5
+ } from '@equinor/fusion-observable';
6
+ import { actions, type Actions } from './history.actions';
7
+ import { flowCreators, createFlow, type HistoryFlowCreator } from './history.flows';
8
+ import type { HistoryStack, LocationState } from '../types';
9
+
10
+ /**
11
+ * History state containing flow state and stack.
12
+ */
13
+ export type HistoryState = FlowState<LocationState, typeof actions> & {
14
+ stack: HistoryStack;
15
+ };
16
+
17
+ /**
18
+ * Default flows for history state management.
19
+ */
20
+ export const defaultFlows = [flowCreators.navigate, flowCreators.go, flowCreators.pop];
21
+
22
+ /**
23
+ * Creates a history store with the specified stack and reducer.
24
+ *
25
+ * @param stack - The history stack implementation
26
+ * @param reducer - The reducer for history state
27
+ * @param options - Optional configuration options
28
+ * @returns A HistoryState instance
29
+ */
30
+ export const createStore = (
31
+ stack: HistoryStack,
32
+ reducer: ReducerWithInitialState<LocationState, Actions>,
33
+ options?: {
34
+ flows?: HistoryFlowCreator[];
35
+ skipBlockCheck?: boolean;
36
+ validateCurrentLocation?: boolean;
37
+ },
38
+ ): HistoryState => {
39
+ // Create base state with actions and reducer, then add stack property
40
+ // The stack is stored alongside the flow state for use in flows
41
+ const state = Object.assign(createState(actions, reducer), { stack }) as HistoryState;
42
+
43
+ // Build set of flows to use (default flows or custom flows)
44
+ // Use Set to ensure no duplicates if validateCurrentLocation is added
45
+ const flows = new Set<HistoryFlowCreator>(options?.flows ?? defaultFlows);
46
+ // Optionally add validation flow to check location consistency
47
+ if (options?.validateCurrentLocation) {
48
+ flows.add(flowCreators.validateCurrentLocation);
49
+ }
50
+
51
+ // Create combined flow from all flow creators
52
+ // This merges all flows so they process actions in parallel
53
+ const flow = createFlow([...flows], {
54
+ skipBlockCheck: options?.skipBlockCheck,
55
+ });
56
+
57
+ // Initialize the flow with the stack and add it to the state subject
58
+ // The flow processes actions and updates state through the reducer
59
+ state.subject.addFlow(flow(stack));
60
+
61
+ return state;
62
+ };
@@ -0,0 +1,3 @@
1
+ export { createStore, type HistoryState } from './history.state';
2
+ export { createHistoryReducer } from './history.reducer';
3
+ export { actions, type Actions } from './history.actions';
@@ -0,0 +1,159 @@
1
+ import type { Observable } from 'rxjs';
2
+ import type { Actions } from './state/history.actions';
3
+
4
+ /**
5
+ * Actions represent the type of change to a location value.
6
+ */
7
+ export enum Action {
8
+ /**
9
+ * A POP indicates a change to an arbitrary index in the history stack, such
10
+ * as a back or forward navigation. It does not describe the direction of the
11
+ * navigation, only that the current index changed.
12
+ *
13
+ * Note: This is the default action for newly created history objects.
14
+ */
15
+ Pop = 'POP',
16
+ /**
17
+ * A PUSH indicates a new entry being added to the history stack, such as when
18
+ * a link is clicked and a new page loads.
19
+ */
20
+ Push = 'PUSH',
21
+ /**
22
+ * A REPLACE indicates the entry at the current index in the history stack
23
+ * being replaced by a new one.
24
+ */
25
+ Replace = 'REPLACE',
26
+ }
27
+
28
+ /**
29
+ * Target path for navigation operations.
30
+ * Can be a string path or a Path object.
31
+ */
32
+ export type To = string | Partial<Path>;
33
+
34
+ /**
35
+ * Path object containing pathname, optional search, and optional hash.
36
+ */
37
+ export type Path = {
38
+ pathname: string;
39
+ search: string;
40
+ hash: string;
41
+ };
42
+
43
+ /**
44
+ * Location object representing a navigation entry.
45
+ * Extends Path with state and a unique key.
46
+ */
47
+ // biome-ignore lint/suspicious/noExplicitAny: necessary
48
+ export type Location<T = any> = Path & {
49
+ state: T;
50
+ key: string;
51
+ };
52
+
53
+ /**
54
+ * Internal state for history management.
55
+ */
56
+ export type LocationState = {
57
+ current: NavigationUpdate;
58
+ history: NavigationUpdate[];
59
+ blockers: NavigationBlocker[];
60
+ };
61
+
62
+ /**
63
+ * Callback function for listening to navigation changes.
64
+ */
65
+ export type NavigationListener = (update: Readonly<NavigationUpdate>) => void;
66
+
67
+ /**
68
+ * Navigation update event containing action and location.
69
+ */
70
+ export type NavigationUpdate<A extends string = Action, T = unknown> = {
71
+ delta: number;
72
+ action: Readonly<A>;
73
+ location: Readonly<Location<T>>;
74
+ };
75
+
76
+ /**
77
+ * Navigation transition that was blocked.
78
+ * Provides a retry method to allow the navigation to proceed.
79
+ */
80
+ export interface NavigationTransition extends NavigationUpdate {
81
+ /**
82
+ * Retries the navigation that was blocked.
83
+ */
84
+ retry(): void;
85
+ }
86
+
87
+ /**
88
+ * Callback function for blocking navigation attempts.
89
+ */
90
+ export type NavigationBlocker = (transition: NavigationTransition) => void;
91
+
92
+ /**
93
+ * Options for controlling navigation behavior.
94
+ *
95
+ * @interface NavigateOptions
96
+ *
97
+ * @property {boolean} [replace] - Determines the navigation action:
98
+ * - `false` (default): Pushes a new entry onto the history stack
99
+ * - `true`: Replaces the current entry in the history stack
100
+ *
101
+ * @property {unknown} [state] - Optional state object to associate with the navigation entry.
102
+ * This state can be accessed later when navigating back to this entry.
103
+ */
104
+ export interface NavigateOptions {
105
+ replace?: boolean;
106
+ state?: unknown;
107
+ }
108
+
109
+ /**
110
+ * History interface for managing navigation state.
111
+ * Compatible with industry-standard routers (Remix/React Router) and provides observable state management.
112
+ */
113
+ export interface History extends Disposable {
114
+ /** Observable stream of navigation state updates. */
115
+ readonly state$: Observable<NavigationUpdate>;
116
+ /** Observable stream of navigation actions. */
117
+ readonly action$: Observable<Actions>;
118
+ /** Current navigation action. */
119
+ readonly action: Action;
120
+ /** Current location in the history stack. */
121
+ readonly location: Location;
122
+
123
+ /** Creates a valid href string for a given path. */
124
+ createHref(to: To): string;
125
+ /** Creates a URL object for a given path. */
126
+ createURL(to: To): URL;
127
+ /** Encodes a location by properly URL-encoding the pathname. */
128
+ encodeLocation(to: To): Path;
129
+ /** Pushes a new navigation entry onto the history stack. */
130
+ push(to: To, state?: unknown): void;
131
+ /** Replaces the current history entry with a new one. */
132
+ replace(to: To, state?: unknown): void;
133
+ /** Navigate to a location with explicit options. */
134
+ navigate(to: To, options?: NavigateOptions): void;
135
+ /** Navigates backward or forward in the history stack. */
136
+ go(delta: number): void;
137
+ /** Sets up a listener for navigation changes. */
138
+ listen(listener: NavigationListener): () => void;
139
+ /** Registers a blocker to intercept navigation attempts. */
140
+ block(blocker: NavigationBlocker): VoidFunction;
141
+ }
142
+
143
+ /**
144
+ * History stack interface for managing navigation entries.
145
+ */
146
+ export interface HistoryStack {
147
+ /** Origin URL for the history stack. */
148
+ readonly origin: string;
149
+ /** Current location in the stack. */
150
+ readonly current: Location;
151
+ /** Pushes a new entry onto the history stack. */
152
+ push(location: Location): void;
153
+ /** Replaces the current entry in the history stack. */
154
+ replace(location: Location): void;
155
+ /** Navigates backward or forward in the history stack. */
156
+ go(delta: number, state: Readonly<LocationState>): void;
157
+ /** Creates a URL object for a given path. */
158
+ createURL(to: To): URL;
159
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Encodes trailing whitespace in a string by replacing it with '%20'.
3
+ *
4
+ * @param input - The string to encode
5
+ * @returns The string with trailing whitespace encoded
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * encodeTrailingWhitespace('hello ')
10
+ * // 'hello%20'
11
+ *
12
+ * encodeTrailingWhitespace('hello')
13
+ * // 'hello'
14
+ * ```
15
+ */
16
+ export const encodeTrailingWhitespace = (input: string): string => {
17
+ return input.replace(/ $/, '%20');
18
+ };
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Checks if a string has a protocol prefix.
3
+ *
4
+ * @param input - The string to check
5
+ * @returns True if the string starts with a protocol (e.g., 'http:', 'https:', 'file:')
6
+ *
7
+ * @example
8
+ * ```ts
9
+ * hasProtocol('https://example.com')
10
+ * // true
11
+ *
12
+ * hasProtocol('/users')
13
+ * // false
14
+ *
15
+ * hasProtocol('file:///path/to/file')
16
+ * // true
17
+ * ```
18
+ */
19
+ export const hasProtocol = (input: string): boolean => {
20
+ return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(input);
21
+ };
@@ -0,0 +1,5 @@
1
+ export { pathToString } from './path-to-string';
2
+ export { pathToUrl } from './path-to-url';
3
+ export { resolvePath } from './resolve-path';
4
+ export { resolveWindowLocation } from './resolve-browser-location';
5
+ export { resolveHashLocation } from './resolve-browser-location';
@@ -0,0 +1,24 @@
1
+ import type { Path } from '../types';
2
+ import { encodeTrailingWhitespace } from './encode-trailing-whitespace';
3
+
4
+ /**
5
+ * Converts a Partial<Path> object to a path string.
6
+ *
7
+ * @param to - The path object with optional pathname, search, and hash
8
+ * @returns A path string with properly formatted search and hash
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * pathToString({ pathname: '/users', search: '?id=1', hash: '#section' })
13
+ * // '/users?id=1#section'
14
+ *
15
+ * pathToString({ pathname: '/users', search: 'id=1', hash: 'section' })
16
+ * // '/users?id=1#section'
17
+ * ```
18
+ */
19
+ export const pathToString = (to: Partial<Path>): string => {
20
+ const pathname = encodeTrailingWhitespace(to.pathname ?? '/');
21
+ const search = encodeTrailingWhitespace(to.search?.replace(/^\?/, '') ?? '');
22
+ const hash = encodeTrailingWhitespace(to.hash?.replace(/^#/, '') ?? '');
23
+ return `${pathname}${search ? `?${search}` : ''}${hash ? `#${hash}` : ''}`;
24
+ };