@ahnseungchan/webviewkit 1.0.1 → 1.0.3
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 +149 -0
- package/dist/packages/core/src/__tests__/back-router.test.d.ts +0 -0
- package/dist/packages/core/src/__tests__/back-router.test.js +62 -0
- package/dist/packages/core/src/__tests__/history.test.d.ts +1 -0
- package/dist/packages/core/src/__tests__/history.test.js +114 -0
- package/dist/packages/core/src/__tests__/stack-router.test.d.ts +0 -0
- package/dist/packages/core/src/__tests__/stack-router.test.js +185 -0
- package/dist/packages/core/src/history.d.ts +10 -0
- package/dist/packages/core/src/history.js +82 -0
- package/dist/packages/core/src/index.d.ts +5 -0
- package/dist/packages/core/src/index.js +6 -0
- package/dist/packages/core/src/stack-router/__tests__/stack-router.test.d.ts +0 -0
- package/dist/packages/core/src/stack-router/__tests__/stack-router.test.js +153 -0
- package/dist/packages/core/src/stack-router/_components/back-router.d.ts +2 -0
- package/dist/packages/core/src/stack-router/_components/back-router.js +34 -0
- package/dist/packages/core/src/stack-router/_components/header.d.ts +2 -0
- package/dist/packages/core/src/stack-router/_components/header.js +36 -0
- package/dist/packages/core/src/stack-router/stack-router-provider.d.ts +25 -0
- package/dist/packages/core/src/stack-router/stack-router-provider.js +211 -0
- package/dist/packages/core/src/type.d.ts +34 -0
- package/dist/packages/core/src/type.js +11 -0
- package/package.json +1 -1
package/README.md
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
# WebViewKit
|
|
2
|
+
|
|
3
|
+
A modern React library for building stack-based navigation in WebView applications, providing smooth transitions and native-like navigation experience.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- 🚀 **Stack-based Navigation**: Native-like navigation with push/pop transitions
|
|
8
|
+
- 🎨 **Smooth Animations**: GSAP-powered animations for fluid page transitions
|
|
9
|
+
- 📱 **Mobile-First**: Designed specifically for WebView and mobile applications
|
|
10
|
+
- 🔧 **TypeScript Support**: Full TypeScript support with comprehensive type definitions
|
|
11
|
+
- 🎯 **Flexible**: Customizable animations, layouts, and navigation behavior
|
|
12
|
+
|
|
13
|
+
## Installation
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @webviewkit
|
|
17
|
+
# or
|
|
18
|
+
yarn add @webviewkit
|
|
19
|
+
# or
|
|
20
|
+
pnpm add @webviewkit
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
## Quick Start
|
|
24
|
+
|
|
25
|
+
```tsx
|
|
26
|
+
import React from "react";
|
|
27
|
+
import { StackRouterProvider } from "@webviewkit";
|
|
28
|
+
|
|
29
|
+
function App() {
|
|
30
|
+
return (
|
|
31
|
+
<StackRouterProvider
|
|
32
|
+
maxWidth="600px"
|
|
33
|
+
Activities={[
|
|
34
|
+
{
|
|
35
|
+
path: "/",
|
|
36
|
+
element: <HomePage />,
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
path: "/about",
|
|
40
|
+
element: <AboutPage />,
|
|
41
|
+
},
|
|
42
|
+
]}
|
|
43
|
+
initialActivity={{ path: "/", element: <HomePage /> }}
|
|
44
|
+
/>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
## Usage
|
|
50
|
+
|
|
51
|
+
### Basic Navigation
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
import { useStackRouter } from "@webviewkit/core";
|
|
55
|
+
|
|
56
|
+
function HomePage() {
|
|
57
|
+
const { push, back } = useStackRouter();
|
|
58
|
+
|
|
59
|
+
return (
|
|
60
|
+
<div>
|
|
61
|
+
<h1>Home</h1>
|
|
62
|
+
<button onClick={() => push("/about")}>Go to About</button>
|
|
63
|
+
<button onClick={back}>Back</button>
|
|
64
|
+
</div>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
### History Management
|
|
70
|
+
|
|
71
|
+
```tsx
|
|
72
|
+
import { history } from "@webviewkit";
|
|
73
|
+
|
|
74
|
+
// Navigate to a new page
|
|
75
|
+
await history.push("/new-page", { data: "some data" });
|
|
76
|
+
|
|
77
|
+
// Replace current page
|
|
78
|
+
await history.replace("/replacement-page", { data: "replacement data" });
|
|
79
|
+
|
|
80
|
+
// Go back
|
|
81
|
+
await history.back();
|
|
82
|
+
|
|
83
|
+
// Go forward
|
|
84
|
+
await history.forward();
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
## API Reference
|
|
88
|
+
|
|
89
|
+
### StackRouterProvider Props
|
|
90
|
+
|
|
91
|
+
| Prop | Type | Default | Description |
|
|
92
|
+
| ----------------- | ------------------------------------------------- | --------- | ------------------------------------- |
|
|
93
|
+
| `maxWidth` | `string` | `"600px"` | Maximum width of the layout container |
|
|
94
|
+
| `isShowHeader` | `boolean` | `true` | Whether to show the header |
|
|
95
|
+
| `Activities` | `Array<{path: string, element: React.ReactNode}>` | `[]` | Array of available routes |
|
|
96
|
+
| `initialActivity` | `{path: string, element: React.ReactNode}` | - | Initial route to display |
|
|
97
|
+
|
|
98
|
+
### useStackRouter Hook
|
|
99
|
+
|
|
100
|
+
Returns an object with navigation methods:
|
|
101
|
+
|
|
102
|
+
- `currentPath`: Current active path
|
|
103
|
+
- `currentState`: Current history state
|
|
104
|
+
- `push(path, data?)`: Navigate to a new page
|
|
105
|
+
- `replace(path, data?)`: Replace current page
|
|
106
|
+
- `back()`: Go back to previous page
|
|
107
|
+
- `forward()`: Go forward to next page
|
|
108
|
+
|
|
109
|
+
### History API
|
|
110
|
+
|
|
111
|
+
- `history.push(path, data?)`: Add new history entry
|
|
112
|
+
- `history.replace(path, data?)`: Replace current history entry
|
|
113
|
+
- `history.back()`: Go back to previous entry
|
|
114
|
+
- `history.forward()`: Go forward to next entry
|
|
115
|
+
- `history.go(delta)`: Go to specific history entry
|
|
116
|
+
- `history.getCurrentState()`: Get current history state
|
|
117
|
+
- `history.addListener(listener)`: Add history change listener
|
|
118
|
+
|
|
119
|
+
## Examples
|
|
120
|
+
|
|
121
|
+
See the `packages/browser` directory for complete examples.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
# Install dependencies
|
|
127
|
+
pnpm install
|
|
128
|
+
|
|
129
|
+
# Build all packages
|
|
130
|
+
pnpm build
|
|
131
|
+
|
|
132
|
+
# Run tests
|
|
133
|
+
pnpm test
|
|
134
|
+
|
|
135
|
+
# Run linting
|
|
136
|
+
pnpm lint
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
## Contributing
|
|
140
|
+
|
|
141
|
+
1. Fork the repository
|
|
142
|
+
2. Create your feature branch (`git checkout -b feature/amazing-feature`)
|
|
143
|
+
3. Commit your changes (`git commit -m 'Add some amazing feature'`)
|
|
144
|
+
4. Push to the branch (`git push origin feature/amazing-feature`)
|
|
145
|
+
5. Open a Pull Request
|
|
146
|
+
|
|
147
|
+
## License
|
|
148
|
+
|
|
149
|
+
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
|
File without changes
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// import React from "react";
|
|
3
|
+
// import { render, screen, fireEvent } from "@testing-library/react";
|
|
4
|
+
// import BackRouter from "../stack-router/_components/back-router";
|
|
5
|
+
// import userEvent from "@testing-library/user-event";
|
|
6
|
+
// import { useStackRouter } from "../stack-router/stack-router-provider";
|
|
7
|
+
// jest.mock("../stack-router/stack-router-provider", () => ({
|
|
8
|
+
// useStackRouter: jest.fn(),
|
|
9
|
+
// }));
|
|
10
|
+
// describe("BackRouter", () => {
|
|
11
|
+
// const mockBack = jest.fn();
|
|
12
|
+
// const user = userEvent.setup();
|
|
13
|
+
// beforeEach(() => {
|
|
14
|
+
// // Reset all mocks before each test
|
|
15
|
+
// jest.clearAllMocks();
|
|
16
|
+
// // Setup default mock implementation
|
|
17
|
+
// (useStackRouter as jest.Mock).mockReturnValue({
|
|
18
|
+
// back: mockBack,
|
|
19
|
+
// });
|
|
20
|
+
// });
|
|
21
|
+
// it("renders back button with icon", () => {
|
|
22
|
+
// render(<BackRouter />);
|
|
23
|
+
// // Check if button exists
|
|
24
|
+
// const button = screen.getByRole("button");
|
|
25
|
+
// expect(button).toBeInTheDocument();
|
|
26
|
+
// // Check if SVG icon exists
|
|
27
|
+
// const svg = button.querySelector("svg");
|
|
28
|
+
// expect(svg).toBeInTheDocument();
|
|
29
|
+
// });
|
|
30
|
+
// it("calls back function when clicked", () => {
|
|
31
|
+
// render(<BackRouter />);
|
|
32
|
+
// const button = screen.getByRole("button");
|
|
33
|
+
// fireEvent.click(button);
|
|
34
|
+
// expect(mockBack).toHaveBeenCalledTimes(1);
|
|
35
|
+
// });
|
|
36
|
+
// it("applies correct styles", () => {
|
|
37
|
+
// render(<BackRouter />);
|
|
38
|
+
// const button = screen.getByRole("button");
|
|
39
|
+
// // Check initial styles
|
|
40
|
+
// expect(button).toHaveStyle({
|
|
41
|
+
// width: "40px",
|
|
42
|
+
// height: "40px",
|
|
43
|
+
// padding: "8px",
|
|
44
|
+
// borderRadius: "50%",
|
|
45
|
+
// });
|
|
46
|
+
// // Check if SVG has correct attributes
|
|
47
|
+
// const svg = button.querySelector("svg");
|
|
48
|
+
// expect(svg).toHaveAttribute("width", "32");
|
|
49
|
+
// expect(svg).toHaveAttribute("height", "32");
|
|
50
|
+
// expect(svg).toHaveAttribute("viewBox", "0 0 24 24");
|
|
51
|
+
// });
|
|
52
|
+
// it("handles hover and active states", async () => {
|
|
53
|
+
// render(<BackRouter />);
|
|
54
|
+
// const button = screen.getByRole("button");
|
|
55
|
+
// // expect hover
|
|
56
|
+
// await user.hover(button);
|
|
57
|
+
// expect(button).toHaveStyle({ color: "#666666" });
|
|
58
|
+
// // expect active
|
|
59
|
+
// await user.pointer({ keys: "[MouseLeft]", target: button });
|
|
60
|
+
// expect(button).toHaveStyle({ color: "#999999" });
|
|
61
|
+
// });
|
|
62
|
+
// });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
import { history } from "../history";
|
|
2
|
+
// mocking window.history
|
|
3
|
+
const mockHistory = {
|
|
4
|
+
state: null,
|
|
5
|
+
pushState: jest.fn(),
|
|
6
|
+
replaceState: jest.fn(),
|
|
7
|
+
back: jest.fn(),
|
|
8
|
+
forward: jest.fn(),
|
|
9
|
+
go: jest.fn(),
|
|
10
|
+
};
|
|
11
|
+
// mocking window.location
|
|
12
|
+
const mockLocation = {
|
|
13
|
+
pathname: "/",
|
|
14
|
+
};
|
|
15
|
+
// mocking global window
|
|
16
|
+
Object.defineProperty(window, "history", {
|
|
17
|
+
value: mockHistory,
|
|
18
|
+
writable: true,
|
|
19
|
+
});
|
|
20
|
+
Object.defineProperty(window, "location", {
|
|
21
|
+
value: mockLocation,
|
|
22
|
+
writable: true,
|
|
23
|
+
});
|
|
24
|
+
describe("history", () => {
|
|
25
|
+
beforeEach(() => {
|
|
26
|
+
jest.clearAllMocks();
|
|
27
|
+
mockHistory.state = null;
|
|
28
|
+
mockLocation.pathname = "/";
|
|
29
|
+
});
|
|
30
|
+
describe("push", () => {
|
|
31
|
+
it("should push new history entry", () => {
|
|
32
|
+
const path = "/test";
|
|
33
|
+
const data = { title: "Test" };
|
|
34
|
+
history.push(path, data);
|
|
35
|
+
expect(mockHistory.pushState).toHaveBeenCalledWith({ path, data, action: "PUSH" }, "", path);
|
|
36
|
+
});
|
|
37
|
+
});
|
|
38
|
+
describe("replace", () => {
|
|
39
|
+
it("should replace current history entry", () => {
|
|
40
|
+
const path = "/test";
|
|
41
|
+
const data = { title: "Test" };
|
|
42
|
+
history.replace(path, data);
|
|
43
|
+
expect(mockHistory.replaceState).toHaveBeenCalledWith({ path, data, action: "REPLACE" }, "", path);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
describe("navigation methods", () => {
|
|
47
|
+
it("should call history.back()", () => {
|
|
48
|
+
history.back();
|
|
49
|
+
expect(mockHistory.back).toHaveBeenCalled();
|
|
50
|
+
});
|
|
51
|
+
it("should call history.forward()", () => {
|
|
52
|
+
history.forward();
|
|
53
|
+
expect(mockHistory.forward).toHaveBeenCalled();
|
|
54
|
+
});
|
|
55
|
+
it("should call history.go()", () => {
|
|
56
|
+
const delta = 2;
|
|
57
|
+
history.go(delta);
|
|
58
|
+
expect(mockHistory.go).toHaveBeenCalledWith(delta);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
describe("getCurrentState", () => {
|
|
62
|
+
it("should return current state", () => {
|
|
63
|
+
const mockState = {
|
|
64
|
+
path: "/test",
|
|
65
|
+
data: { title: "Test" },
|
|
66
|
+
};
|
|
67
|
+
mockHistory.state = mockState;
|
|
68
|
+
const state = history.getCurrentState();
|
|
69
|
+
expect(state).toEqual(mockState);
|
|
70
|
+
});
|
|
71
|
+
it("should return default state when history.state is null", () => {
|
|
72
|
+
mockHistory.state = null;
|
|
73
|
+
mockLocation.pathname = "/default";
|
|
74
|
+
const state = history.getCurrentState();
|
|
75
|
+
expect(state).toEqual({ path: "/default" });
|
|
76
|
+
});
|
|
77
|
+
});
|
|
78
|
+
describe("addListener", () => {
|
|
79
|
+
it("should add and remove listener", () => {
|
|
80
|
+
const listener = jest.fn();
|
|
81
|
+
const removeListener = history.addListener(listener);
|
|
82
|
+
const testState = {
|
|
83
|
+
path: "/test",
|
|
84
|
+
};
|
|
85
|
+
const popStateEvent = new PopStateEvent("popstate", {
|
|
86
|
+
state: testState,
|
|
87
|
+
});
|
|
88
|
+
window.dispatchEvent(popStateEvent);
|
|
89
|
+
expect(listener).toHaveBeenCalledWith(testState);
|
|
90
|
+
// Delete listener
|
|
91
|
+
removeListener();
|
|
92
|
+
// change state and dispatch event
|
|
93
|
+
const newState = { path: "/new" };
|
|
94
|
+
const newPopStateEvent = new PopStateEvent("popstate", {
|
|
95
|
+
state: newState,
|
|
96
|
+
});
|
|
97
|
+
window.dispatchEvent(newPopStateEvent);
|
|
98
|
+
// listener should not be called anymore
|
|
99
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
100
|
+
});
|
|
101
|
+
it("should not add duplicate listener", () => {
|
|
102
|
+
const listener = jest.fn();
|
|
103
|
+
// add listener twice
|
|
104
|
+
history.addListener(listener);
|
|
105
|
+
history.addListener(listener);
|
|
106
|
+
// change state and dispatch event
|
|
107
|
+
const testState = { path: "/test" };
|
|
108
|
+
const popStateEvent = new PopStateEvent("popstate", { state: testState });
|
|
109
|
+
window.dispatchEvent(popStateEvent);
|
|
110
|
+
// listener should be called only once
|
|
111
|
+
expect(listener).toHaveBeenCalledTimes(1);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
});
|
|
File without changes
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// import React from "react";
|
|
3
|
+
// import {
|
|
4
|
+
// render,
|
|
5
|
+
// screen,
|
|
6
|
+
// fireEvent,
|
|
7
|
+
// act,
|
|
8
|
+
// waitFor,
|
|
9
|
+
// } from "@testing-library/react";
|
|
10
|
+
// import StackRouter, {
|
|
11
|
+
// useStackRouter,
|
|
12
|
+
// } from "../stack-router/stack-router-provider";
|
|
13
|
+
// import { history } from "../history";
|
|
14
|
+
// // Test component that uses useStackRouter
|
|
15
|
+
// const MockPushComponent = ({ path }: { path: string }) => {
|
|
16
|
+
// const { push } = useStackRouter();
|
|
17
|
+
// return (
|
|
18
|
+
// <button
|
|
19
|
+
// onClick={() => push(path)}
|
|
20
|
+
// data-testid={`push-to-${path.replace("/", "")}`}
|
|
21
|
+
// >
|
|
22
|
+
// Navigate to {path}
|
|
23
|
+
// </button>
|
|
24
|
+
// );
|
|
25
|
+
// };
|
|
26
|
+
// const MockBackComponent = ({ path }: { path: string }) => {
|
|
27
|
+
// const { back } = useStackRouter();
|
|
28
|
+
// return (
|
|
29
|
+
// <button onClick={back} data-testid={`back-to-${path.replace("/", "")}`}>
|
|
30
|
+
// Back
|
|
31
|
+
// </button>
|
|
32
|
+
// );
|
|
33
|
+
// };
|
|
34
|
+
// // Mock components for testing
|
|
35
|
+
// const MockPage1 = () => (
|
|
36
|
+
// <div data-testid="page1">
|
|
37
|
+
// <MockPushComponent path="/page2" />
|
|
38
|
+
// Page1
|
|
39
|
+
// </div>
|
|
40
|
+
// );
|
|
41
|
+
// const MockPage2 = () => (
|
|
42
|
+
// <div data-testid="page2">
|
|
43
|
+
// <MockPushComponent path="/page3" />
|
|
44
|
+
// <MockBackComponent path="/page1" />
|
|
45
|
+
// Page2
|
|
46
|
+
// </div>
|
|
47
|
+
// );
|
|
48
|
+
// const MockPage3 = () => (
|
|
49
|
+
// <div data-testid="page3">
|
|
50
|
+
// <MockBackComponent path="/page2" />
|
|
51
|
+
// Page3
|
|
52
|
+
// </div>
|
|
53
|
+
// );
|
|
54
|
+
// const mockActivities = [
|
|
55
|
+
// { path: "/page1", element: <MockPage1 /> },
|
|
56
|
+
// { path: "/page2", element: <MockPage2 /> },
|
|
57
|
+
// { path: "/page3", element: <MockPage3 /> },
|
|
58
|
+
// ];
|
|
59
|
+
// // Helper function to wait for animations and state updates
|
|
60
|
+
// const waitForStateUpdate = async () => {
|
|
61
|
+
// await act(async () => {
|
|
62
|
+
// await new Promise((resolve) => setTimeout(resolve, 100));
|
|
63
|
+
// });
|
|
64
|
+
// };
|
|
65
|
+
// describe("StackRouter", () => {
|
|
66
|
+
// it("renders initial page correctly", () => {
|
|
67
|
+
// render(
|
|
68
|
+
// <StackRouter
|
|
69
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
70
|
+
// Activities={mockActivities}
|
|
71
|
+
// />
|
|
72
|
+
// );
|
|
73
|
+
// expect(screen.getByTestId("page1")).toBeInTheDocument();
|
|
74
|
+
// });
|
|
75
|
+
// it("navigates to new page when push is called", async () => {
|
|
76
|
+
// render(
|
|
77
|
+
// <StackRouter
|
|
78
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
79
|
+
// Activities={mockActivities}
|
|
80
|
+
// />
|
|
81
|
+
// );
|
|
82
|
+
// // Click the navigation button
|
|
83
|
+
// await act(async () => {
|
|
84
|
+
// fireEvent.click(screen.getByTestId("push-to-page2"));
|
|
85
|
+
// await waitForStateUpdate();
|
|
86
|
+
// });
|
|
87
|
+
// await waitFor(() => {
|
|
88
|
+
// expect(screen.getByText("Page2")).toBeInTheDocument();
|
|
89
|
+
// });
|
|
90
|
+
// });
|
|
91
|
+
// it("goes back when back is called", async () => {
|
|
92
|
+
// render(
|
|
93
|
+
// <StackRouter
|
|
94
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
95
|
+
// Activities={mockActivities}
|
|
96
|
+
// />
|
|
97
|
+
// );
|
|
98
|
+
// // First push to page2
|
|
99
|
+
// await act(async () => {
|
|
100
|
+
// fireEvent.click(screen.getByTestId("push-to-page2"));
|
|
101
|
+
// await waitForStateUpdate();
|
|
102
|
+
// });
|
|
103
|
+
// // Then go back
|
|
104
|
+
// await act(async () => {
|
|
105
|
+
// fireEvent.click(screen.getByTestId("back-to-page1"));
|
|
106
|
+
// await waitForStateUpdate();
|
|
107
|
+
// });
|
|
108
|
+
// await waitFor(() => {
|
|
109
|
+
// expect(screen.getByText("Page1")).toBeInTheDocument();
|
|
110
|
+
// });
|
|
111
|
+
// });
|
|
112
|
+
// it("handles multiple page transitions correctly", async () => {
|
|
113
|
+
// render(
|
|
114
|
+
// <StackRouter
|
|
115
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
116
|
+
// Activities={mockActivities}
|
|
117
|
+
// />
|
|
118
|
+
// );
|
|
119
|
+
// // Navigate through pages
|
|
120
|
+
// await act(async () => {
|
|
121
|
+
// fireEvent.click(screen.getByTestId("push-to-page2"));
|
|
122
|
+
// await waitForStateUpdate();
|
|
123
|
+
// });
|
|
124
|
+
// await waitFor(() => {
|
|
125
|
+
// expect(screen.getByText("Page2")).toBeInTheDocument();
|
|
126
|
+
// });
|
|
127
|
+
// await act(async () => {
|
|
128
|
+
// fireEvent.click(screen.getByTestId("push-to-page3"));
|
|
129
|
+
// await waitForStateUpdate();
|
|
130
|
+
// });
|
|
131
|
+
// await waitFor(() => {
|
|
132
|
+
// expect(screen.getByText("Page3")).toBeInTheDocument();
|
|
133
|
+
// });
|
|
134
|
+
// // Go back twice
|
|
135
|
+
// await act(async () => {
|
|
136
|
+
// fireEvent.click(screen.getByTestId("back-to-page2"));
|
|
137
|
+
// await waitForStateUpdate();
|
|
138
|
+
// });
|
|
139
|
+
// await waitFor(() => {
|
|
140
|
+
// expect(screen.getByText("Page2")).toBeInTheDocument();
|
|
141
|
+
// });
|
|
142
|
+
// await act(async () => {
|
|
143
|
+
// fireEvent.click(screen.getByTestId("back-to-page1"));
|
|
144
|
+
// await waitForStateUpdate();
|
|
145
|
+
// });
|
|
146
|
+
// await waitFor(() => {
|
|
147
|
+
// expect(screen.getByText("Page1")).toBeInTheDocument();
|
|
148
|
+
// });
|
|
149
|
+
// });
|
|
150
|
+
// it("maintains correct page order in stack", async () => {
|
|
151
|
+
// const { rerender } = render(
|
|
152
|
+
// <StackRouter
|
|
153
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
154
|
+
// Activities={mockActivities}
|
|
155
|
+
// />
|
|
156
|
+
// );
|
|
157
|
+
// // Navigate to page2
|
|
158
|
+
// await act(async () => {
|
|
159
|
+
// fireEvent.click(screen.getByTestId("push-to-page2"));
|
|
160
|
+
// await waitForStateUpdate();
|
|
161
|
+
// });
|
|
162
|
+
// await waitFor(() => {
|
|
163
|
+
// expect(screen.getByText("Page2")).toBeInTheDocument();
|
|
164
|
+
// });
|
|
165
|
+
// // Navigate to page3
|
|
166
|
+
// await act(async () => {
|
|
167
|
+
// fireEvent.click(screen.getByTestId("push-to-page3"));
|
|
168
|
+
// await waitForStateUpdate();
|
|
169
|
+
// });
|
|
170
|
+
// await waitFor(() => {
|
|
171
|
+
// expect(screen.getByText("Page3")).toBeInTheDocument();
|
|
172
|
+
// });
|
|
173
|
+
// });
|
|
174
|
+
// it("handles maxWidth prop correctly", () => {
|
|
175
|
+
// render(
|
|
176
|
+
// <StackRouter
|
|
177
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
178
|
+
// Activities={mockActivities}
|
|
179
|
+
// maxWidth="800px"
|
|
180
|
+
// />
|
|
181
|
+
// );
|
|
182
|
+
// const layout = screen.getByTestId("layout");
|
|
183
|
+
// expect(layout).toHaveStyle({ maxWidth: "800px" });
|
|
184
|
+
// });
|
|
185
|
+
// });
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { HistoryListener, HistoryState } from "./type";
|
|
2
|
+
export declare const history: {
|
|
3
|
+
push: (path: string, data?: any) => Promise<void>;
|
|
4
|
+
replace: (path: string, data: any) => Promise<void>;
|
|
5
|
+
back: () => Promise<void>;
|
|
6
|
+
forward: () => Promise<void>;
|
|
7
|
+
go: (delta: number) => Promise<void>;
|
|
8
|
+
getCurrentState: () => HistoryState;
|
|
9
|
+
addListener: (listener: HistoryListener) => () => boolean;
|
|
10
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { HistoryAction } from "./type";
|
|
2
|
+
const listeners = new Set();
|
|
3
|
+
const handlePopState = (event) => {
|
|
4
|
+
const state = event.state;
|
|
5
|
+
notifyListeners(state);
|
|
6
|
+
};
|
|
7
|
+
// notify to listeners
|
|
8
|
+
const notifyListeners = (state) => {
|
|
9
|
+
listeners.forEach((listener) => listener(state));
|
|
10
|
+
};
|
|
11
|
+
// initialize
|
|
12
|
+
window.addEventListener("popstate", handlePopState);
|
|
13
|
+
export const history = {
|
|
14
|
+
// add new history entry
|
|
15
|
+
push: async (path, data) => {
|
|
16
|
+
const state = {
|
|
17
|
+
path,
|
|
18
|
+
data,
|
|
19
|
+
action: HistoryAction.PUSH,
|
|
20
|
+
};
|
|
21
|
+
window.history.pushState(state, "", path);
|
|
22
|
+
// 애니메이션을 위한 지연
|
|
23
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
24
|
+
notifyListeners(state);
|
|
25
|
+
},
|
|
26
|
+
// replace current history entry
|
|
27
|
+
replace: async (path, data) => {
|
|
28
|
+
const state = {
|
|
29
|
+
path,
|
|
30
|
+
data,
|
|
31
|
+
action: HistoryAction.REPLACE,
|
|
32
|
+
};
|
|
33
|
+
window.history.replaceState(state, "", path);
|
|
34
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
35
|
+
notifyListeners(state);
|
|
36
|
+
},
|
|
37
|
+
// back to previous history entry
|
|
38
|
+
back: async () => {
|
|
39
|
+
window.history.back();
|
|
40
|
+
await new Promise((resolve) => {
|
|
41
|
+
const handlePopState = () => {
|
|
42
|
+
window.removeEventListener("popstate", handlePopState);
|
|
43
|
+
resolve();
|
|
44
|
+
};
|
|
45
|
+
window.addEventListener("popstate", handlePopState);
|
|
46
|
+
});
|
|
47
|
+
const state = {
|
|
48
|
+
path: window.location.pathname,
|
|
49
|
+
action: HistoryAction.POP,
|
|
50
|
+
};
|
|
51
|
+
notifyListeners(state);
|
|
52
|
+
},
|
|
53
|
+
// forward to next history entry
|
|
54
|
+
forward: async () => {
|
|
55
|
+
window.history.forward();
|
|
56
|
+
const state = {
|
|
57
|
+
path: window.location.pathname,
|
|
58
|
+
action: HistoryAction.PUSH,
|
|
59
|
+
};
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
61
|
+
notifyListeners(state);
|
|
62
|
+
},
|
|
63
|
+
// go to specific history entry
|
|
64
|
+
go: async (delta) => {
|
|
65
|
+
window.history.go(delta);
|
|
66
|
+
const state = {
|
|
67
|
+
path: window.location.pathname,
|
|
68
|
+
action: HistoryAction.PUSH,
|
|
69
|
+
};
|
|
70
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
71
|
+
notifyListeners(state);
|
|
72
|
+
},
|
|
73
|
+
// get current history entry
|
|
74
|
+
getCurrentState: () => window.history.state || {
|
|
75
|
+
path: window.location.pathname,
|
|
76
|
+
},
|
|
77
|
+
// change listener to history
|
|
78
|
+
addListener: (listener) => {
|
|
79
|
+
listeners.add(listener);
|
|
80
|
+
return () => listeners.delete(listener);
|
|
81
|
+
},
|
|
82
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type { StackRouterProviderProps, VisiblePage, DirectionType, HistoryState, HistoryListener, } from "./type";
|
|
2
|
+
export { Direction, HistoryAction } from "./type";
|
|
3
|
+
export { history } from "./history";
|
|
4
|
+
export { default as StackRouterProvider } from "./stack-router/stack-router-provider";
|
|
5
|
+
export { useStackRouter } from "./stack-router/stack-router-provider";
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { Direction, HistoryAction } from "./type";
|
|
2
|
+
// Export history API
|
|
3
|
+
export { history } from "./history";
|
|
4
|
+
// Export stack router components
|
|
5
|
+
export { default as StackRouterProvider } from "./stack-router/stack-router-provider";
|
|
6
|
+
export { useStackRouter } from "./stack-router/stack-router-provider";
|
|
File without changes
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// import React from "react";
|
|
3
|
+
// import { render, screen, fireEvent, act } from "@testing-library/react";
|
|
4
|
+
// import StackRouter from "../stack-router-provider";
|
|
5
|
+
// import { history } from "../../history";
|
|
6
|
+
// // Mock components for testing
|
|
7
|
+
// const MockPage1 = () => <div data-testid="page1">Page 1</div>;
|
|
8
|
+
// const MockPage2 = () => <div data-testid="page2">Page 2</div>;
|
|
9
|
+
// const MockPage3 = () => <div data-testid="page3">Page 3</div>;
|
|
10
|
+
// const mockActivities = [
|
|
11
|
+
// { path: "/page1", element: <MockPage1 /> },
|
|
12
|
+
// { path: "/page2", element: <MockPage2 /> },
|
|
13
|
+
// { path: "/page3", element: <MockPage3 /> },
|
|
14
|
+
// ];
|
|
15
|
+
// // Helper function to wait for animations
|
|
16
|
+
// const waitForAnimation = async () => {
|
|
17
|
+
// await act(async () => {
|
|
18
|
+
// await new Promise((resolve) => setTimeout(resolve, 700)); // 애니메이션 시간과 동일하게 설정
|
|
19
|
+
// });
|
|
20
|
+
// };
|
|
21
|
+
// describe("StackRouter", () => {
|
|
22
|
+
// beforeEach(() => {
|
|
23
|
+
// // Reset history before each test
|
|
24
|
+
// history.push("/page1");
|
|
25
|
+
// });
|
|
26
|
+
// it("renders initial page correctly", () => {
|
|
27
|
+
// render(
|
|
28
|
+
// <StackRouter
|
|
29
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
30
|
+
// Activities={mockActivities}
|
|
31
|
+
// />
|
|
32
|
+
// );
|
|
33
|
+
// expect(screen.getByTestId("page1")).toBeInTheDocument();
|
|
34
|
+
// });
|
|
35
|
+
// it("navigates to new page when push is called", async () => {
|
|
36
|
+
// const { rerender } = render(
|
|
37
|
+
// <StackRouter
|
|
38
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
39
|
+
// Activities={mockActivities}
|
|
40
|
+
// />
|
|
41
|
+
// );
|
|
42
|
+
// // Mock history.push
|
|
43
|
+
// history.push("/page2");
|
|
44
|
+
// // Wait for animation
|
|
45
|
+
// await waitForAnimation();
|
|
46
|
+
// rerender(
|
|
47
|
+
// <StackRouter
|
|
48
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
49
|
+
// Activities={mockActivities}
|
|
50
|
+
// />
|
|
51
|
+
// );
|
|
52
|
+
// // 페이지가 렌더링될 때까지 대기
|
|
53
|
+
// await screen.findByTestId("page2");
|
|
54
|
+
// expect(screen.getByTestId("page2")).toBeInTheDocument();
|
|
55
|
+
// });
|
|
56
|
+
// it("goes back when back is called", async () => {
|
|
57
|
+
// const { rerender } = render(
|
|
58
|
+
// <StackRouter
|
|
59
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
60
|
+
// Activities={mockActivities}
|
|
61
|
+
// />
|
|
62
|
+
// );
|
|
63
|
+
// // First push to page2
|
|
64
|
+
// history.push("/page2");
|
|
65
|
+
// await waitForAnimation();
|
|
66
|
+
// // Then go back
|
|
67
|
+
// history.back();
|
|
68
|
+
// await waitForAnimation();
|
|
69
|
+
// rerender(
|
|
70
|
+
// <StackRouter
|
|
71
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
72
|
+
// Activities={mockActivities}
|
|
73
|
+
// />
|
|
74
|
+
// );
|
|
75
|
+
// // 페이지가 렌더링될 때까지 대기
|
|
76
|
+
// await screen.findByTestId("page1");
|
|
77
|
+
// expect(screen.getByTestId("page1")).toBeInTheDocument();
|
|
78
|
+
// });
|
|
79
|
+
// it("handles multiple page transitions correctly", async () => {
|
|
80
|
+
// const { rerender } = render(
|
|
81
|
+
// <StackRouter
|
|
82
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
83
|
+
// Activities={mockActivities}
|
|
84
|
+
// />
|
|
85
|
+
// );
|
|
86
|
+
// // Navigate through pages
|
|
87
|
+
// history.push("/page2");
|
|
88
|
+
// await waitForAnimation();
|
|
89
|
+
// history.push("/page3");
|
|
90
|
+
// await waitForAnimation();
|
|
91
|
+
// rerender(
|
|
92
|
+
// <StackRouter
|
|
93
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
94
|
+
// Activities={mockActivities}
|
|
95
|
+
// />
|
|
96
|
+
// );
|
|
97
|
+
// // 페이지가 렌더링될 때까지 대기
|
|
98
|
+
// await screen.findByTestId("page3");
|
|
99
|
+
// expect(screen.getByTestId("page3")).toBeInTheDocument();
|
|
100
|
+
// // Go back twice
|
|
101
|
+
// history.back();
|
|
102
|
+
// await waitForAnimation();
|
|
103
|
+
// history.back();
|
|
104
|
+
// await waitForAnimation();
|
|
105
|
+
// rerender(
|
|
106
|
+
// <StackRouter
|
|
107
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
108
|
+
// Activities={mockActivities}
|
|
109
|
+
// />
|
|
110
|
+
// );
|
|
111
|
+
// // 페이지가 렌더링될 때까지 대기
|
|
112
|
+
// await screen.findByTestId("page1");
|
|
113
|
+
// expect(screen.getByTestId("page1")).toBeInTheDocument();
|
|
114
|
+
// });
|
|
115
|
+
// it("maintains correct page order in stack", async () => {
|
|
116
|
+
// const { rerender } = render(
|
|
117
|
+
// <StackRouter
|
|
118
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
119
|
+
// Activities={mockActivities}
|
|
120
|
+
// />
|
|
121
|
+
// );
|
|
122
|
+
// // Navigate to page2
|
|
123
|
+
// history.push("/page2");
|
|
124
|
+
// await waitForAnimation();
|
|
125
|
+
// // Navigate to page3
|
|
126
|
+
// history.push("/page3");
|
|
127
|
+
// await waitForAnimation();
|
|
128
|
+
// rerender(
|
|
129
|
+
// <StackRouter
|
|
130
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
131
|
+
// Activities={mockActivities}
|
|
132
|
+
// />
|
|
133
|
+
// );
|
|
134
|
+
// // 모든 페이지가 렌더링될 때까지 대기
|
|
135
|
+
// await screen.findByTestId("page3");
|
|
136
|
+
// // Check if all pages are in the correct order
|
|
137
|
+
// const pages = screen.getAllByTestId(/page[1-3]/);
|
|
138
|
+
// expect(pages[0]).toHaveTextContent("Page 1");
|
|
139
|
+
// expect(pages[1]).toHaveTextContent("Page 2");
|
|
140
|
+
// expect(pages[2]).toHaveTextContent("Page 3");
|
|
141
|
+
// });
|
|
142
|
+
// it("handles maxWidth prop correctly", () => {
|
|
143
|
+
// const { container } = render(
|
|
144
|
+
// <StackRouter
|
|
145
|
+
// initialActivity={{ path: "/page1", element: <MockPage1 /> }}
|
|
146
|
+
// Activities={mockActivities}
|
|
147
|
+
// maxWidth="800px"
|
|
148
|
+
// />
|
|
149
|
+
// );
|
|
150
|
+
// const layout = container.querySelector('div[style*="max-width: 800px"]');
|
|
151
|
+
// expect(layout).toBeInTheDocument();
|
|
152
|
+
// });
|
|
153
|
+
// });
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import styled from "@emotion/styled";
|
|
3
|
+
import { useStackRouter } from "../stack-router-provider";
|
|
4
|
+
const BackRouter = () => {
|
|
5
|
+
const { back } = useStackRouter();
|
|
6
|
+
return (_jsx(BackButton, { onClick: back, children: _jsx(BackIcon, {}) }));
|
|
7
|
+
};
|
|
8
|
+
export default BackRouter;
|
|
9
|
+
/**
|
|
10
|
+
styles
|
|
11
|
+
*/
|
|
12
|
+
const BackButton = styled.button `
|
|
13
|
+
display: flex;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
width: 40px;
|
|
17
|
+
height: 40px;
|
|
18
|
+
border: none;
|
|
19
|
+
background: none;
|
|
20
|
+
cursor: pointer;
|
|
21
|
+
padding: 8px;
|
|
22
|
+
border-radius: 50%;
|
|
23
|
+
transition: all 0.2s;
|
|
24
|
+
color: #000000;
|
|
25
|
+
|
|
26
|
+
&:hover {
|
|
27
|
+
color: #666666;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
&:active {
|
|
31
|
+
color: #999999;
|
|
32
|
+
}
|
|
33
|
+
`;
|
|
34
|
+
const BackIcon = () => (_jsx("svg", { width: "32", height: "32", viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: _jsx("path", { d: "M12 20L4 12L12 4", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) }));
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState, useEffect } from "react";
|
|
3
|
+
import styled from "@emotion/styled";
|
|
4
|
+
import BackRouter from "./back-router";
|
|
5
|
+
import { useStackRouter } from "../stack-router-provider";
|
|
6
|
+
const Header = () => {
|
|
7
|
+
const { currentPath, visiblePages } = useStackRouter();
|
|
8
|
+
const [displayPath, setDisplayPath] = useState(currentPath);
|
|
9
|
+
const [isVisible, setIsVisible] = useState(visiblePages.length > 1);
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
const timer = setTimeout(() => {
|
|
12
|
+
setDisplayPath(currentPath);
|
|
13
|
+
setIsVisible(visiblePages.length > 1);
|
|
14
|
+
}, 500);
|
|
15
|
+
return () => clearTimeout(timer);
|
|
16
|
+
}, [currentPath, visiblePages]);
|
|
17
|
+
return (_jsxs(StyledHeader, { children: [isVisible && _jsx(BackRouter, {}), _jsx(StyledTitle, { children: displayPath })] }));
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
styles
|
|
21
|
+
*/
|
|
22
|
+
const StyledHeader = styled.header `
|
|
23
|
+
width: 100%;
|
|
24
|
+
height: 64px;
|
|
25
|
+
background-color: white;
|
|
26
|
+
border-bottom: 1px solid #e5e7eb;
|
|
27
|
+
display: flex;
|
|
28
|
+
align-items: center;
|
|
29
|
+
padding: 0 1rem;
|
|
30
|
+
`;
|
|
31
|
+
const StyledTitle = styled.h1 `
|
|
32
|
+
font-size: 1.25rem;
|
|
33
|
+
font-weight: 600;
|
|
34
|
+
color: #111827;
|
|
35
|
+
`;
|
|
36
|
+
export default Header;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { HistoryState, StackRouterProviderProps } from "../type";
|
|
3
|
+
interface StackRouterContextProps {
|
|
4
|
+
currentPath: string;
|
|
5
|
+
currentState: HistoryState;
|
|
6
|
+
Activities: {
|
|
7
|
+
path: string;
|
|
8
|
+
element: React.ReactNode;
|
|
9
|
+
}[];
|
|
10
|
+
initialActivity: {
|
|
11
|
+
path: string;
|
|
12
|
+
element: React.ReactNode;
|
|
13
|
+
};
|
|
14
|
+
push: (path: string, data?: any) => void;
|
|
15
|
+
replace: (path: string, data?: any) => void;
|
|
16
|
+
back: () => void;
|
|
17
|
+
forward: () => void;
|
|
18
|
+
visiblePages: {
|
|
19
|
+
path: string;
|
|
20
|
+
element: React.ReactNode;
|
|
21
|
+
}[];
|
|
22
|
+
}
|
|
23
|
+
export declare const useStackRouter: () => StackRouterContextProps;
|
|
24
|
+
declare const StackRouter: ({ maxWidth, Activities, initialActivity, }: StackRouterProviderProps) => import("react/jsx-runtime").JSX.Element;
|
|
25
|
+
export default StackRouter;
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
+
import { createContext, useContext, useEffect, useState } from "react";
|
|
3
|
+
import { history } from "../history";
|
|
4
|
+
import styled from "@emotion/styled";
|
|
5
|
+
import { keyframes, css } from "@emotion/react";
|
|
6
|
+
import { HistoryAction, Direction, } from "../type";
|
|
7
|
+
const StackRouterContext = createContext(null);
|
|
8
|
+
export const useStackRouter = () => {
|
|
9
|
+
const context = useContext(StackRouterContext);
|
|
10
|
+
if (!context) {
|
|
11
|
+
throw new Error("useStackRouter must be used within a StackRouterProvider");
|
|
12
|
+
}
|
|
13
|
+
return context;
|
|
14
|
+
};
|
|
15
|
+
const StackRouter = ({ maxWidth = "600px", Activities = [], initialActivity, }) => {
|
|
16
|
+
const [currentPath, setCurrentPath] = useState(history.getCurrentState().path);
|
|
17
|
+
const [currentState, setCurrentState] = useState(history.getCurrentState());
|
|
18
|
+
const [isTransitioning, setIsTransitioning] = useState(false);
|
|
19
|
+
const [direction, setDirection] = useState(Direction.FORWARD);
|
|
20
|
+
const [visiblePages, setVisiblePages] = useState([]);
|
|
21
|
+
// initialize visible pages
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
setVisiblePages([
|
|
24
|
+
{
|
|
25
|
+
path: initialActivity.path,
|
|
26
|
+
element: initialActivity.element,
|
|
27
|
+
isEntering: true,
|
|
28
|
+
},
|
|
29
|
+
]);
|
|
30
|
+
}, [initialActivity]);
|
|
31
|
+
// listen to history changes
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const removeListener = history.addListener((state) => {
|
|
34
|
+
if (!isTransitioning || direction !== Direction.BACKWARD) {
|
|
35
|
+
setCurrentPath(state?.path);
|
|
36
|
+
setCurrentState(state);
|
|
37
|
+
}
|
|
38
|
+
setIsTransitioning(true);
|
|
39
|
+
setDirection(state?.action === HistoryAction.POP
|
|
40
|
+
? Direction.BACKWARD
|
|
41
|
+
: Direction.FORWARD);
|
|
42
|
+
});
|
|
43
|
+
return () => {
|
|
44
|
+
removeListener();
|
|
45
|
+
};
|
|
46
|
+
}, [isTransitioning, direction]);
|
|
47
|
+
// handle back
|
|
48
|
+
const handleBack = () => {
|
|
49
|
+
setIsTransitioning(true);
|
|
50
|
+
setDirection(Direction.BACKWARD);
|
|
51
|
+
setTimeout(() => {
|
|
52
|
+
setVisiblePages((prev) => prev.slice(0, -1));
|
|
53
|
+
}, 500);
|
|
54
|
+
history.back();
|
|
55
|
+
};
|
|
56
|
+
// update current path
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
if (visiblePages.length > 0) {
|
|
59
|
+
setCurrentPath(visiblePages[visiblePages.length - 1].path);
|
|
60
|
+
}
|
|
61
|
+
}, [visiblePages]);
|
|
62
|
+
// handle push
|
|
63
|
+
const handlePush = (path, data) => {
|
|
64
|
+
setIsTransitioning(true);
|
|
65
|
+
setDirection(Direction.FORWARD);
|
|
66
|
+
const currentActivity = Activities.find((a) => a.path === path);
|
|
67
|
+
if (currentActivity) {
|
|
68
|
+
setVisiblePages((prev) => {
|
|
69
|
+
const existingIndex = prev.findIndex((a) => a.path === path);
|
|
70
|
+
if (existingIndex !== -1) {
|
|
71
|
+
const newActivities = [...prev];
|
|
72
|
+
const [removed] = newActivities.splice(existingIndex, 1);
|
|
73
|
+
return [...newActivities, { ...removed, isExiting: true }];
|
|
74
|
+
}
|
|
75
|
+
return [
|
|
76
|
+
...prev,
|
|
77
|
+
{ path, element: currentActivity.element, isExiting: true },
|
|
78
|
+
];
|
|
79
|
+
});
|
|
80
|
+
setTimeout(() => {
|
|
81
|
+
setCurrentPath(path);
|
|
82
|
+
}, 500);
|
|
83
|
+
}
|
|
84
|
+
history.push(path, data);
|
|
85
|
+
};
|
|
86
|
+
const value = {
|
|
87
|
+
currentPath,
|
|
88
|
+
currentState,
|
|
89
|
+
initialActivity,
|
|
90
|
+
Activities,
|
|
91
|
+
visiblePages,
|
|
92
|
+
push: handlePush,
|
|
93
|
+
replace: history.replace,
|
|
94
|
+
back: handleBack,
|
|
95
|
+
forward: history.forward,
|
|
96
|
+
};
|
|
97
|
+
return (_jsx(StackRouterContext.Provider, { value: value, children: _jsx(LayoutContainer, { children: _jsx(Layout, { maxWidth: maxWidth, as: "div", "data-testid": "layout", children: _jsx(PageContainer, { children: visiblePages.map((page, index) => (_jsx(PageWrapper, { isTransitioning: isTransitioning, direction: direction, currentPage: index === visiblePages.length - 1, isEntering: page.isEntering, isExiting: page.isExiting, zIndex: index, children: page.element }, `${page.path}-${index}`))) }) }) }) }));
|
|
98
|
+
};
|
|
99
|
+
export default StackRouter;
|
|
100
|
+
/**styles */
|
|
101
|
+
const LayoutContainer = styled.div `
|
|
102
|
+
width: 100%;
|
|
103
|
+
height: 100vh;
|
|
104
|
+
display: flex;
|
|
105
|
+
justify-content: center;
|
|
106
|
+
background: white;
|
|
107
|
+
`;
|
|
108
|
+
const Layout = styled.div `
|
|
109
|
+
width: 100%;
|
|
110
|
+
max-width: ${({ maxWidth }) => maxWidth};
|
|
111
|
+
height: 100%;
|
|
112
|
+
position: relative;
|
|
113
|
+
overflow: hidden;
|
|
114
|
+
`;
|
|
115
|
+
const PageContainer = styled.div `
|
|
116
|
+
position: relative;
|
|
117
|
+
width: 100%;
|
|
118
|
+
height: 100%;
|
|
119
|
+
overflow: hidden;
|
|
120
|
+
background: white;
|
|
121
|
+
`;
|
|
122
|
+
const slideInRight = keyframes `
|
|
123
|
+
from {
|
|
124
|
+
transform: translateX(100%);
|
|
125
|
+
opacity: 0.2;
|
|
126
|
+
}
|
|
127
|
+
to {
|
|
128
|
+
transform: translateX(0);
|
|
129
|
+
opacity: 1;
|
|
130
|
+
}
|
|
131
|
+
`;
|
|
132
|
+
const slideOutLeft = keyframes `
|
|
133
|
+
from {
|
|
134
|
+
transform: translateX(0);
|
|
135
|
+
opacity: 1;
|
|
136
|
+
}
|
|
137
|
+
to {
|
|
138
|
+
transform: translateX(-30%);
|
|
139
|
+
opacity: 0.2;
|
|
140
|
+
}
|
|
141
|
+
`;
|
|
142
|
+
const slideInLeft = keyframes `
|
|
143
|
+
from {
|
|
144
|
+
transform: translateX(-30%);
|
|
145
|
+
opacity: 0.2;
|
|
146
|
+
}
|
|
147
|
+
to {
|
|
148
|
+
transform: translateX(0);
|
|
149
|
+
opacity: 1;
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
const slideOutRight = keyframes `
|
|
153
|
+
from {
|
|
154
|
+
transform: translateX(0);
|
|
155
|
+
opacity: 1;
|
|
156
|
+
}
|
|
157
|
+
to {
|
|
158
|
+
transform: translateX(100%);
|
|
159
|
+
opacity: 0.2;
|
|
160
|
+
}
|
|
161
|
+
`;
|
|
162
|
+
const PageWrapper = styled.div `
|
|
163
|
+
position: absolute;
|
|
164
|
+
top: 0;
|
|
165
|
+
left: 0;
|
|
166
|
+
width: 100%;
|
|
167
|
+
height: 100%;
|
|
168
|
+
background: white;
|
|
169
|
+
cursor: grab;
|
|
170
|
+
user-select: none;
|
|
171
|
+
-webkit-user-select: none;
|
|
172
|
+
-webkit-touch-callout: none;
|
|
173
|
+
-webkit-tap-highlight-color: transparent;
|
|
174
|
+
z-index: ${({ zIndex }) => zIndex};
|
|
175
|
+
will-change: transform, opacity;
|
|
176
|
+
transform: translate3d(0, 0, 0);
|
|
177
|
+
animation: ${({ isTransitioning, direction, currentPage, isEntering, isExiting, }) => {
|
|
178
|
+
if (!isTransitioning)
|
|
179
|
+
return "none";
|
|
180
|
+
if (direction === "forward") {
|
|
181
|
+
if (isExiting) {
|
|
182
|
+
return css `
|
|
183
|
+
${slideInRight} 0.7s ease-in-out forwards
|
|
184
|
+
`;
|
|
185
|
+
}
|
|
186
|
+
if (isEntering) {
|
|
187
|
+
return css `
|
|
188
|
+
${slideOutLeft} 0.7s ease-in-out forwards
|
|
189
|
+
`;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
if (isExiting) {
|
|
194
|
+
return css `
|
|
195
|
+
${slideOutRight} 0.7s ease-in-out forwards
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
if (isEntering) {
|
|
199
|
+
return css `
|
|
200
|
+
${slideInLeft} 0.7s ease-in-out forwards
|
|
201
|
+
`;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return "none";
|
|
205
|
+
}};
|
|
206
|
+
animation-fill-mode: forwards;
|
|
207
|
+
|
|
208
|
+
&:active {
|
|
209
|
+
cursor: grabbing;
|
|
210
|
+
}
|
|
211
|
+
`;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export interface StackRouterProviderProps {
|
|
2
|
+
maxWidth?: string;
|
|
3
|
+
isShowHeader?: boolean;
|
|
4
|
+
Activities?: {
|
|
5
|
+
path: string;
|
|
6
|
+
element: React.ReactNode;
|
|
7
|
+
}[];
|
|
8
|
+
initialActivity: {
|
|
9
|
+
path: string;
|
|
10
|
+
element: React.ReactNode;
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
export interface VisiblePage {
|
|
14
|
+
path: string;
|
|
15
|
+
element: React.ReactNode;
|
|
16
|
+
isEntering?: boolean;
|
|
17
|
+
isExiting?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export declare enum Direction {
|
|
20
|
+
FORWARD = "forward",
|
|
21
|
+
BACKWARD = "backward"
|
|
22
|
+
}
|
|
23
|
+
export type DirectionType = Direction.FORWARD | Direction.BACKWARD;
|
|
24
|
+
export declare enum HistoryAction {
|
|
25
|
+
PUSH = "PUSH",
|
|
26
|
+
POP = "POP",
|
|
27
|
+
REPLACE = "REPLACE"
|
|
28
|
+
}
|
|
29
|
+
export interface HistoryState {
|
|
30
|
+
path: string;
|
|
31
|
+
data?: any;
|
|
32
|
+
action?: HistoryAction.PUSH | HistoryAction.POP | HistoryAction.REPLACE;
|
|
33
|
+
}
|
|
34
|
+
export type HistoryListener = (state: HistoryState) => void;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export var Direction;
|
|
2
|
+
(function (Direction) {
|
|
3
|
+
Direction["FORWARD"] = "forward";
|
|
4
|
+
Direction["BACKWARD"] = "backward";
|
|
5
|
+
})(Direction || (Direction = {}));
|
|
6
|
+
export var HistoryAction;
|
|
7
|
+
(function (HistoryAction) {
|
|
8
|
+
HistoryAction["PUSH"] = "PUSH";
|
|
9
|
+
HistoryAction["POP"] = "POP";
|
|
10
|
+
HistoryAction["REPLACE"] = "REPLACE";
|
|
11
|
+
})(HistoryAction || (HistoryAction = {}));
|