@wsxjs/wsx-router 0.0.5

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.
@@ -0,0 +1,260 @@
1
+ import { createLogger } from "@wsxjs/wsx-core";
2
+
3
+ const logger = createLogger("RouterUtils");
4
+
5
+ /**
6
+ * 路由信息接口
7
+ */
8
+ export interface RouteInfo {
9
+ path: string;
10
+ params: Record<string, string>;
11
+ query: Record<string, string>;
12
+ hash: string;
13
+ meta?: Record<string, string | number | boolean>;
14
+ }
15
+
16
+ /**
17
+ * 路由匹配结果
18
+ */
19
+ export interface RouteMatch {
20
+ route: string;
21
+ params: Record<string, string>;
22
+ exact: boolean;
23
+ }
24
+
25
+ /**
26
+ * 路由工具类 - 提供路由相关的辅助函数
27
+ */
28
+ export class RouterUtils {
29
+ /**
30
+ * 编程式导航
31
+ */
32
+ static navigate(path: string, replace = false): void {
33
+ if (replace) {
34
+ window.history.replaceState(null, "", path);
35
+ } else {
36
+ window.history.pushState(null, "", path);
37
+ }
38
+
39
+ // 触发路由变化事件
40
+ window.dispatchEvent(new PopStateEvent("popstate"));
41
+ logger.debug(`Navigated to: ${path} (replace: ${replace})`);
42
+ }
43
+
44
+ /**
45
+ * 获取当前路由信息
46
+ */
47
+ static getCurrentRoute(): RouteInfo {
48
+ const url = new URL(window.location.href);
49
+ return {
50
+ path: url.pathname,
51
+ params: {}, // 需要路由匹配后才能确定
52
+ query: Object.fromEntries(url.searchParams.entries()),
53
+ hash: url.hash.slice(1), // 移除 # 号
54
+ };
55
+ }
56
+
57
+ /**
58
+ * 解析路由路径,提取参数
59
+ */
60
+ static parseRoute(route: string, path: string): RouteMatch | null {
61
+ // 精确匹配
62
+ if (route === path) {
63
+ return {
64
+ route,
65
+ params: {},
66
+ exact: true,
67
+ };
68
+ }
69
+
70
+ // 通配符匹配
71
+ if (route === "*") {
72
+ return {
73
+ route,
74
+ params: {},
75
+ exact: false,
76
+ };
77
+ }
78
+
79
+ // 参数匹配
80
+ if (route.includes(":")) {
81
+ const paramNames = route.match(/:([^/]+)/g)?.map((p) => p.slice(1)) || [];
82
+ const pattern = route.replace(/:[^/]+/g, "([^/]+)");
83
+ const regex = new RegExp(`^${pattern}$`);
84
+ const matches = path.match(regex);
85
+
86
+ if (matches && paramNames.length > 0) {
87
+ const params: Record<string, string> = {};
88
+ paramNames.forEach((name, index) => {
89
+ params[name] = matches[index + 1];
90
+ });
91
+
92
+ return {
93
+ route,
94
+ params,
95
+ exact: true,
96
+ };
97
+ }
98
+ }
99
+
100
+ // 前缀匹配(用于嵌套路由)
101
+ if (route.endsWith("/*")) {
102
+ const prefix = route.slice(0, -2);
103
+ if (path.startsWith(prefix)) {
104
+ return {
105
+ route,
106
+ params: {},
107
+ exact: false,
108
+ };
109
+ }
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ /**
116
+ * 构建路由路径,替换参数
117
+ */
118
+ static buildPath(route: string, params: Record<string, string> = {}): string {
119
+ let path = route;
120
+
121
+ // 替换路径参数
122
+ Object.entries(params).forEach(([key, value]) => {
123
+ path = path.replace(`:${key}`, encodeURIComponent(value));
124
+ });
125
+
126
+ return path;
127
+ }
128
+
129
+ /**
130
+ * 检查路由是否匹配当前路径
131
+ */
132
+ static isRouteActive(route: string, exact = false): boolean {
133
+ const currentPath = window.location.pathname;
134
+
135
+ if (exact) {
136
+ return currentPath === route;
137
+ }
138
+
139
+ // 对于根路径,需要精确匹配
140
+ if (route === "/") {
141
+ return currentPath === "/";
142
+ }
143
+
144
+ return currentPath.startsWith(route);
145
+ }
146
+
147
+ /**
148
+ * 获取路由层级
149
+ */
150
+ static getRouteDepth(path: string): number {
151
+ return path.split("/").filter((segment) => segment.length > 0).length;
152
+ }
153
+
154
+ /**
155
+ * 获取父级路由
156
+ */
157
+ static getParentRoute(path: string): string {
158
+ const segments = path.split("/").filter((segment) => segment.length > 0);
159
+ if (segments.length <= 1) {
160
+ return "/";
161
+ }
162
+ segments.pop();
163
+ return "/" + segments.join("/");
164
+ }
165
+
166
+ /**
167
+ * 合并路由路径
168
+ */
169
+ static joinPaths(...paths: string[]): string {
170
+ return paths
171
+ .map((path) => path.replace(/^\/+|\/+$/g, "")) // 移除首尾斜杠
172
+ .filter((path) => path.length > 0)
173
+ .join("/")
174
+ .replace(/^/, "/"); // 添加开头斜杠
175
+ }
176
+
177
+ /**
178
+ * 检查是否为外部链接
179
+ */
180
+ static isExternalUrl(url: string): boolean {
181
+ return /^https?:\/\//.test(url) || /^mailto:/.test(url) || /^tel:/.test(url);
182
+ }
183
+
184
+ /**
185
+ * 获取查询参数
186
+ */
187
+ static getQueryParam(key: string): string | null {
188
+ const url = new URL(window.location.href);
189
+ return url.searchParams.get(key);
190
+ }
191
+
192
+ /**
193
+ * 设置查询参数
194
+ */
195
+ static setQueryParam(key: string, value: string, replace = false): void {
196
+ const url = new URL(window.location.href);
197
+ url.searchParams.set(key, value);
198
+
199
+ const newUrl = url.pathname + url.search + url.hash;
200
+ this.navigate(newUrl, replace);
201
+ }
202
+
203
+ /**
204
+ * 删除查询参数
205
+ */
206
+ static removeQueryParam(key: string, replace = false): void {
207
+ const url = new URL(window.location.href);
208
+ url.searchParams.delete(key);
209
+
210
+ const newUrl = url.pathname + url.search + url.hash;
211
+ this.navigate(newUrl, replace);
212
+ }
213
+
214
+ /**
215
+ * 返回上一页
216
+ */
217
+ static goBack(): void {
218
+ window.history.back();
219
+ }
220
+
221
+ /**
222
+ * 前进一页
223
+ */
224
+ static goForward(): void {
225
+ window.history.forward();
226
+ }
227
+
228
+ /**
229
+ * 替换当前页面
230
+ */
231
+ static replace(path: string): void {
232
+ this.navigate(path, true);
233
+ }
234
+
235
+ /**
236
+ * 获取历史记录长度
237
+ */
238
+ static getHistoryLength(): number {
239
+ return window.history.length;
240
+ }
241
+
242
+ /**
243
+ * 监听路由变化
244
+ */
245
+ static onRouteChange(callback: (route: RouteInfo) => void): () => void {
246
+ const handler = () => {
247
+ const route = this.getCurrentRoute();
248
+ callback(route);
249
+ };
250
+
251
+ window.addEventListener("popstate", handler);
252
+ document.addEventListener("route-changed", handler);
253
+
254
+ // 返回清理函数
255
+ return () => {
256
+ window.removeEventListener("popstate", handler);
257
+ document.removeEventListener("route-changed", handler);
258
+ };
259
+ }
260
+ }
@@ -0,0 +1,74 @@
1
+ /* WSX Link 基础样式 - 使用 CSS Parts 允许宿主自定义 */
2
+ :host {
3
+ display: inline-block;
4
+ min-width: fit-content;
5
+ min-height: fit-content;
6
+ width: auto;
7
+ height: auto;
8
+ }
9
+
10
+ .wsx-link {
11
+ color: var(--link-color, #007bff);
12
+ text-decoration: var(--link-decoration, underline);
13
+ cursor: pointer;
14
+ transition: color 0.2s ease;
15
+ display: inline-block;
16
+ min-height: 1.2em;
17
+ line-height: 1.2;
18
+ }
19
+
20
+ .wsx-link:hover {
21
+ color: var(--link-hover-color, #0056b3);
22
+ text-decoration: var(--link-hover-decoration, underline);
23
+ }
24
+
25
+ .wsx-link:focus {
26
+ outline: 2px solid var(--link-focus-color, #007bff);
27
+ outline-offset: 2px;
28
+ }
29
+
30
+ /* 激活状态 */
31
+ .wsx-link.active {
32
+ color: var(--link-active-color, #6c757d);
33
+ font-weight: var(--link-active-weight, bold);
34
+ }
35
+
36
+ /* 禁用状态 */
37
+ :host([disabled]) .wsx-link {
38
+ color: var(--link-disabled-color, #6c757d);
39
+ cursor: not-allowed;
40
+ pointer-events: none;
41
+ }
42
+
43
+ /* 外部链接图标 */
44
+ :host([external]) .wsx-link::after {
45
+ content: "↗";
46
+ font-size: 0.8em;
47
+ margin-left: 0.2em;
48
+ opacity: 0.7;
49
+ }
50
+
51
+ /* 变体样式 */
52
+ :host([variant="button"]) .wsx-link {
53
+ background-color: var(--button-bg, #007bff);
54
+ color: var(--button-color, white);
55
+ padding: 0.5rem 1rem;
56
+ border-radius: 0.25rem;
57
+ text-decoration: none;
58
+ display: inline-block;
59
+ }
60
+
61
+ :host([variant="button"]) .wsx-link:hover {
62
+ background-color: var(--button-hover-bg, #0056b3);
63
+ color: var(--button-hover-color, white);
64
+ }
65
+
66
+ :host([variant="tab"]) .wsx-link {
67
+ padding: 0.5rem 1rem;
68
+ border-bottom: 2px solid transparent;
69
+ text-decoration: none;
70
+ }
71
+
72
+ :host([variant="tab"]) .wsx-link.active {
73
+ border-bottom-color: var(--tab-active-border, #007bff);
74
+ }
@@ -0,0 +1,162 @@
1
+ /** @jsxImportSource @wsxjs/wsx-core */
2
+ import { WebComponent, autoRegister, createLogger } from "@wsxjs/wsx-core";
3
+ import styles from "./WsxLink.css?inline";
4
+
5
+ const logger = createLogger("WsxLink");
6
+
7
+ /**
8
+ * WSX Link - 路由导航链接组件
9
+ *
10
+ * 属性:
11
+ * - to: 目标路径
12
+ * - replace: 是否替换历史记录(默认 false)
13
+ * - active-class: 激活状态样式类(默认 'active')
14
+ * - exact: 是否精确匹配(默认 false)
15
+ *
16
+ * 使用示例:
17
+ * ```html
18
+ * <wsx-link to="/users">用户列表</wsx-link>
19
+ * <wsx-link to="/users/123" active-class="current">用户详情</wsx-link>
20
+ * <wsx-link to="/settings" replace>设置</wsx-link>
21
+ * ```
22
+ */
23
+ @autoRegister({ tagName: "wsx-link" })
24
+ export default class WsxLink extends WebComponent {
25
+ static observedAttributes = ["to", "replace", "active-class", "exact"];
26
+
27
+ private to: string = "";
28
+ private replace: boolean = false;
29
+ private activeClass: string = "active";
30
+ private exact: boolean = false;
31
+
32
+ constructor() {
33
+ super({
34
+ styles,
35
+ styleName: "wsx-link",
36
+ });
37
+ }
38
+
39
+ render() {
40
+ return (
41
+ <a href={this.to} class="wsx-link" onClick={this.handleClick} part="link">
42
+ <slot></slot>
43
+ </a>
44
+ );
45
+ }
46
+
47
+ protected onConnected() {
48
+ // Initialize attributes from HTML when connected
49
+ this.to = this.getAttribute("to") || "";
50
+ this.replace = this.hasAttribute("replace");
51
+ this.activeClass = this.getAttribute("active-class") || "active";
52
+ this.exact = this.hasAttribute("exact");
53
+
54
+ // Update the href of the rendered link
55
+ const link = this.shadowRoot.querySelector(".wsx-link") as HTMLAnchorElement;
56
+ if (link) {
57
+ link.href = this.to;
58
+ }
59
+
60
+ // 监听路由变化以更新激活状态
61
+ window.addEventListener("popstate", this.updateActiveState);
62
+ document.addEventListener("route-changed", this.updateActiveState);
63
+
64
+ // 初始更新激活状态
65
+ this.updateActiveState();
66
+ }
67
+
68
+ protected onDisconnected() {
69
+ window.removeEventListener("popstate", this.updateActiveState);
70
+ document.removeEventListener("route-changed", this.updateActiveState);
71
+ }
72
+
73
+ protected onAttributeChanged(name: string, _oldValue: string, newValue: string) {
74
+ switch (name) {
75
+ case "to":
76
+ this.to = newValue || "";
77
+ this.rerender(); // Re-render to update href in JSX
78
+ this.updateActiveState();
79
+ break;
80
+ case "replace":
81
+ this.replace = newValue !== null && newValue !== "false";
82
+ break;
83
+ case "active-class":
84
+ this.activeClass = newValue || "active";
85
+ this.updateActiveState();
86
+ break;
87
+ case "exact":
88
+ this.exact = newValue !== null && newValue !== "false";
89
+ this.updateActiveState();
90
+ break;
91
+ }
92
+ }
93
+
94
+ private handleClick = (event: MouseEvent) => {
95
+ // 防止默认链接行为
96
+ event.preventDefault();
97
+
98
+ if (!this.to) {
99
+ logger.warn("No 'to' attribute specified");
100
+ return;
101
+ }
102
+
103
+ // 检查是否为外部链接
104
+ if (this.isExternalLink(this.to)) {
105
+ window.open(this.to, "_blank");
106
+ return;
107
+ }
108
+
109
+ // 使用 History API 进行导航
110
+ if (this.replace) {
111
+ window.history.replaceState(null, "", this.to);
112
+ } else {
113
+ window.history.pushState(null, "", this.to);
114
+ }
115
+
116
+ // 触发路由变化事件
117
+ window.dispatchEvent(new PopStateEvent("popstate"));
118
+
119
+ logger.debug(`Navigated to: ${this.to}`);
120
+ };
121
+
122
+ private updateActiveState = () => {
123
+ const currentPath = window.location.pathname;
124
+ const isActive = this.exact
125
+ ? currentPath === this.to
126
+ : currentPath.startsWith(this.to) && this.to !== "/";
127
+
128
+ const link = this.shadowRoot?.querySelector("a");
129
+ if (link) {
130
+ if (isActive) {
131
+ link.classList.add(this.activeClass);
132
+ this.setAttribute("active", "");
133
+ } else {
134
+ link.classList.remove(this.activeClass);
135
+ this.removeAttribute("active");
136
+ }
137
+ }
138
+ };
139
+
140
+ private isExternalLink(url: string): boolean {
141
+ return (
142
+ url.startsWith("http://") ||
143
+ url.startsWith("https://") ||
144
+ url.startsWith("mailto:") ||
145
+ url.startsWith("tel:")
146
+ );
147
+ }
148
+
149
+ /**
150
+ * 编程式导航
151
+ */
152
+ public navigate() {
153
+ if (this.to) {
154
+ this.handleClick(
155
+ new MouseEvent("click", {
156
+ bubbles: true,
157
+ cancelable: true,
158
+ })
159
+ );
160
+ }
161
+ }
162
+ }
@@ -0,0 +1,11 @@
1
+ /* WSX Router 样式 */
2
+ :host {
3
+ display: block;
4
+ width: 100%;
5
+ height: 100%;
6
+ }
7
+
8
+ .router-outlet {
9
+ width: 100%;
10
+ height: 100%;
11
+ }
@@ -0,0 +1,180 @@
1
+ /** @jsxImportSource @wsxjs/wsx-core */
2
+ import { WebComponent, autoRegister, createLogger } from "@wsxjs/wsx-core";
3
+ import styles from "./WsxRouter.css?inline";
4
+
5
+ const logger = createLogger("WsxRouter");
6
+
7
+ /**
8
+ * WSX Router - 基于原生 History API 的极简路由
9
+ *
10
+ * 设计原则:
11
+ * - 充分利用 History API 和 URL API
12
+ * - 不重新发明轮子,只做优雅封装
13
+ * - 声明式路由配置
14
+ * - 自动拦截导航
15
+ *
16
+ * 使用示例:
17
+ * ```html
18
+ * <wsx-router>
19
+ * <wsx-view route="/" component="home-page"></wsx-view>
20
+ * <wsx-view route="/users/:id" component="user-detail"></wsx-view>
21
+ * <wsx-view route="*" component="not-found"></wsx-view>
22
+ * </wsx-router>
23
+ * ```
24
+ */
25
+ @autoRegister({ tagName: "wsx-router" })
26
+ export default class WsxRouter extends WebComponent {
27
+ private views: Map<string, HTMLElement> = new Map();
28
+ private currentView: HTMLElement | null = null;
29
+
30
+ constructor() {
31
+ super({ styles });
32
+ }
33
+
34
+ render() {
35
+ return (
36
+ <div class="router-outlet">
37
+ <slot></slot>
38
+ </div>
39
+ );
40
+ }
41
+
42
+ protected onConnected() {
43
+ logger.debug("WsxRouter connected to DOM");
44
+
45
+ // 收集所有视图
46
+ this.collectViews();
47
+ logger.debug("WsxRouter collected views:", this.views.size);
48
+
49
+ // 监听原生 popstate 事件
50
+ window.addEventListener("popstate", this.handleRouteChange);
51
+
52
+ // 拦截所有链接点击,让 History API 接管
53
+ this.addEventListener("click", this.interceptLinks);
54
+
55
+ // 初始路由
56
+ this.handleRouteChange();
57
+ }
58
+
59
+ protected onDisconnected() {
60
+ window.removeEventListener("popstate", this.handleRouteChange);
61
+ }
62
+
63
+ private collectViews() {
64
+ // Get the slot element
65
+ const slot = this.shadowRoot?.querySelector("slot");
66
+ if (!slot) {
67
+ logger.error("WsxRouter: No slot found");
68
+ return;
69
+ }
70
+
71
+ // Get slotted elements (light DOM children)
72
+ const slottedElements = slot.assignedElements();
73
+
74
+ // Filter for wsx-view elements
75
+ const views = slottedElements.filter((el) => el.tagName.toLowerCase() === "wsx-view");
76
+ logger.debug("WsxRouter found views:", views.length);
77
+
78
+ views.forEach((view) => {
79
+ const route = view.getAttribute("route") || "/";
80
+ this.views.set(route, view as HTMLElement);
81
+ // 初始隐藏所有视图
82
+ (view as HTMLElement).style.display = "none";
83
+ logger.debug(`WsxRouter hiding view for route: ${route}`);
84
+ });
85
+ }
86
+
87
+ private handleRouteChange = () => {
88
+ const path = window.location.pathname;
89
+ logger.debug(`Route changed to: ${path}`);
90
+
91
+ // 隐藏当前视图
92
+ if (this.currentView) {
93
+ this.currentView.style.display = "none";
94
+ logger.debug("Hiding previous view");
95
+ }
96
+
97
+ // 查找匹配的视图
98
+ const view = this.matchRoute(path);
99
+ if (view) {
100
+ view.style.display = "block";
101
+ this.currentView = view;
102
+ logger.debug(`Showing view for route: ${view.getAttribute("route")}`);
103
+
104
+ // 传递路由参数
105
+ const params = this.extractParams(view.getAttribute("route") || "/", path);
106
+ if (params) {
107
+ view.setAttribute("params", JSON.stringify(params));
108
+ }
109
+ } else {
110
+ logger.warn(`No view found for path: ${path}`);
111
+ }
112
+
113
+ // 触发路由变化事件
114
+ this.dispatchEvent(
115
+ new CustomEvent("route-changed", {
116
+ detail: { path, view },
117
+ bubbles: true,
118
+ composed: true,
119
+ })
120
+ );
121
+ };
122
+
123
+ private matchRoute(path: string): HTMLElement | null {
124
+ // 精确匹配
125
+ if (this.views.has(path)) {
126
+ return this.views.get(path)!;
127
+ }
128
+
129
+ // 参数匹配
130
+ for (const [route, view] of this.views) {
131
+ if (route.includes(":")) {
132
+ const pattern = route.replace(/:[^/]+/g, "([^/]+)");
133
+ const regex = new RegExp(`^${pattern}$`);
134
+ if (regex.test(path)) {
135
+ return view;
136
+ }
137
+ }
138
+ }
139
+
140
+ // 通配符匹配
141
+ return this.views.get("*") || null;
142
+ }
143
+
144
+ private extractParams(route: string, path: string): Record<string, string> | null {
145
+ if (!route.includes(":")) return null;
146
+
147
+ const paramNames = route.match(/:([^/]+)/g)?.map((p) => p.slice(1)) || [];
148
+ const pattern = route.replace(/:[^/]+/g, "([^/]+)");
149
+ const regex = new RegExp(`^${pattern}$`);
150
+ const matches = path.match(regex);
151
+
152
+ if (!matches || !paramNames.length) return null;
153
+
154
+ const params: Record<string, string> = {};
155
+ paramNames.forEach((name, index) => {
156
+ params[name] = matches[index + 1];
157
+ });
158
+
159
+ return params;
160
+ }
161
+
162
+ private interceptLinks = (event: MouseEvent) => {
163
+ const link = (event.target as HTMLElement).closest("a");
164
+ if (!link) return;
165
+
166
+ const href = link.getAttribute("href");
167
+ if (!href || href.startsWith("http") || href.startsWith("#")) return;
168
+
169
+ event.preventDefault();
170
+ this.navigate(href);
171
+ };
172
+
173
+ /**
174
+ * 编程式导航 - 使用原生 History API
175
+ */
176
+ public navigate(path: string) {
177
+ window.history.pushState(null, "", path);
178
+ this.handleRouteChange();
179
+ }
180
+ }