@iyulab/router 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/main.es.js CHANGED
@@ -1,7 +1,7 @@
1
- import { LitElement, html, css, render, nothing } from "lit";
1
+ import { css, LitElement, html, render } from "lit";
2
+ import { property, customElement, state } from "lit/decorators.js";
2
3
  import { createComponent } from "@lit/react";
3
- import React, { createElement } from "react";
4
- import { state, property, customElement } from "lit/decorators.js";
4
+ import React from "react";
5
5
  import { createRoot } from "react-dom/client";
6
6
  function absolutePath(...paths) {
7
7
  paths = paths.map((p) => p.replace(/^\/|\/$/g, "")).filter((p) => p.length > 0);
@@ -52,6 +52,27 @@ function catchBasepath(basepath) {
52
52
  function getRandomID() {
53
53
  return window.isSecureContext ? window.crypto.randomUUID() : window.crypto.getRandomValues(new Uint32Array(1))[0].toString(16);
54
54
  }
55
+ class RouteError extends Error {
56
+ constructor(code, message, original) {
57
+ super(message);
58
+ this.name = "RouteError";
59
+ this.code = code;
60
+ this.original = original;
61
+ this.timestamp = (/* @__PURE__ */ new Date()).toISOString();
62
+ if (Error.captureStackTrace) {
63
+ Error.captureStackTrace(this, RouteError);
64
+ }
65
+ }
66
+ }
67
+ class NotFoundRouteError extends RouteError {
68
+ constructor(path, original) {
69
+ super(404, `Page not found: ${path}`, original);
70
+ this.name = "NotFoundError";
71
+ if (Error.captureStackTrace) {
72
+ Error.captureStackTrace(this, NotFoundRouteError);
73
+ }
74
+ }
75
+ }
55
76
  class RouteEvent extends Event {
56
77
  constructor(type, routeInfo, cancelable = false) {
57
78
  super(type, { bubbles: true, composed: true, cancelable });
@@ -69,14 +90,14 @@ class RouteEvent extends Event {
69
90
  }
70
91
  }
71
92
  }
72
- class RouteStartEvent extends RouteEvent {
93
+ class RouteBeginEvent extends RouteEvent {
73
94
  constructor(routeInfo) {
74
- super("route-start", routeInfo, false);
95
+ super("route-begin", routeInfo, false);
75
96
  }
76
97
  }
77
- class RouteEndEvent extends RouteEvent {
98
+ class RouteDoneEvent extends RouteEvent {
78
99
  constructor(routeInfo) {
79
- super("route-end", routeInfo, false);
100
+ super("route-done", routeInfo, false);
80
101
  }
81
102
  }
82
103
  class RouteErrorEvent extends RouteEvent {
@@ -85,248 +106,82 @@ class RouteErrorEvent extends RouteEvent {
85
106
  this.error = error;
86
107
  }
87
108
  }
88
- var __defProp$1 = Object.defineProperty;
89
- var __decorateClass$1 = (decorators, target, key, kind) => {
90
- var result = void 0;
91
- for (var i = decorators.length - 1, decorator; i >= 0; i--)
92
- if (decorator = decorators[i])
93
- result = decorator(target, key, result) || result;
94
- if (result) __defProp$1(target, key, result);
95
- return result;
96
- };
97
- const EXTERNAL_LINK_PATTERNS = [
98
- /^http/,
99
- /^\/\//,
100
- /^mailto:/,
101
- /^tel:/,
102
- /^javascript:/,
103
- /^ftp:/,
104
- /^data:/,
105
- /^ws:/,
106
- /^wss:/
107
- ];
108
- const _Link = class _Link extends LitElement {
109
- constructor() {
110
- super(...arguments);
111
- this.isExternal = false;
112
- this.anchorHref = "#";
113
- this.handleMouseDown = (event) => {
114
- const isNonNavigationClick = event.button === 2 || event.metaKey || event.shiftKey || event.altKey;
115
- if (event.defaultPrevented || isNonNavigationClick) return;
116
- event.preventDefault();
117
- event.stopPropagation();
118
- const basepath = window.history.state?.basepath || "";
119
- if (event.button === 1 || event.ctrlKey) {
120
- window.open(this.anchorHref, "_blank");
121
- } else if (!this.href) {
122
- this.dispatchPopstate(basepath, basepath);
123
- } else if (this.isExternal || this.href.startsWith("/") && !this.href.startsWith(basepath)) {
124
- window.location.href = this.href;
125
- } else if (this.href.startsWith("#")) {
126
- const url = window.location.pathname + window.location.search + this.href;
127
- this.dispatchHashchange(basepath, url);
128
- } else if (this.href.startsWith("?")) {
129
- const url = window.location.pathname + this.href;
130
- this.dispatchPopstate(basepath, url);
131
- } else {
132
- const url = absolutePath(basepath, this.href);
133
- this.dispatchPopstate(basepath, url);
134
- }
135
- };
136
- this.preventClickEvent = (event) => {
137
- event.preventDefault();
138
- event.stopPropagation();
139
- };
140
- }
141
- connectedCallback() {
142
- super.connectedCallback();
143
- this.addEventListener("mousedown", this.handleMouseDown);
144
- }
145
- disconnectedCallback() {
146
- this.removeEventListener("mousedown", this.handleMouseDown);
147
- super.disconnectedCallback();
148
- }
149
- async updated(changedProperties) {
150
- super.updated(changedProperties);
151
- await this.updateComplete;
152
- if (changedProperties.has("href")) {
153
- this.isExternal = this.checkExternalLink(this.href || "");
154
- this.anchorHref = this.getAnchorHref(this.href);
155
- }
156
- }
157
- render() {
158
- return html`
159
- <a href=${this.anchorHref} @click=${this.preventClickEvent}>
160
- <slot></slot>
161
- </a>
162
- `;
163
- }
164
- /** 클라이언트 라우팅을 위해 popstate 이벤트를 발생시킵니다. */
165
- dispatchPopstate(basepath, url) {
166
- window.history.pushState({ basepath }, "", url);
167
- window.dispatchEvent(new PopStateEvent("popstate"));
168
- }
169
- /** 클라이언트 라우팅을 위해 hashchange 이벤트를 발생시킵니다. */
170
- dispatchHashchange(basepath, url) {
171
- window.history.pushState({ basepath }, "", url);
172
- window.dispatchEvent(new HashChangeEvent("hashchange"));
173
- }
174
- /** 외부 링크인지 확인합니다. */
175
- checkExternalLink(href) {
176
- return EXTERNAL_LINK_PATTERNS.some((pattern) => pattern.test(href));
177
- }
178
- /** a 태그의 href 값을 계산합니다. */
179
- getAnchorHref(href) {
180
- const basepath = window.history.state?.basepath || "";
181
- if (!href) {
182
- return window.location.origin + basepath;
183
- }
184
- if (this.isExternal || href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) {
185
- return href;
186
- }
187
- return absolutePath(basepath, href);
188
- }
189
- };
190
- _Link.styles = css`
191
- :host {
192
- display: inline-flex;
193
- cursor: pointer;
194
- }
195
-
196
- a {
197
- display: contents;
198
- text-decoration: none;
199
- color: inherit;
200
- }
201
- `;
202
- let Link = _Link;
203
- __decorateClass$1([
204
- state()
205
- ], Link.prototype, "anchorHref");
206
- __decorateClass$1([
207
- property({ type: String })
208
- ], Link.prototype, "href");
209
- class Outlet extends LitElement {
210
- /** 쉐도우를 사용하지 않고, 직접 렌더링합니다. */
211
- createRenderRoot() {
212
- return this;
213
- }
214
- render() {
215
- return html`${this.container}`;
216
- }
217
- /**
218
- * 기존 렌더링된 DOM을 제거하고, LitElement를 삽입합니다.
219
- * - 이전 ID와 동일하고, 강제 렌더링이 아닌 경우, 이전 DOM을 반환합니다.
220
- */
221
- async renderElement({ id, element, force }) {
222
- if (this.routeId === id && !force) {
223
- return this.container?.firstElementChild;
224
- }
225
- this.routeId = id;
226
- this.clear();
227
- const template = typeof element === "string" ? document.createElement(element) : element.prototype instanceof LitElement ? new element() : void 0;
228
- if (!template || !this.container) {
229
- throw new Error("DOM이 초기화되지 않았습니다.");
230
- }
231
- this.litRoot = render(html`${template}`, this.container);
232
- this.requestUpdate();
233
- await this.updateComplete;
234
- return template;
235
- }
236
- /**
237
- * 기존 렌더링된 DOM을 제거하고, React 컴포넌트를 삽입합니다.
238
- * - 이전 ID와 동일하고, 강제 렌더링이 아닌 경우, 이전 DOM을 반환합니다.
239
- */
240
- async renderComponent({ id, component, force }) {
241
- if (this.routeId === id && !force) {
242
- return this.container;
243
- }
244
- this.routeId = id;
245
- this.clear();
246
- const template = createElement(component);
247
- if (!template || !this.container) {
248
- throw new Error("DOM이 초기화되지 않았습니다.");
249
- }
250
- this.reactRoot = createRoot(this.container);
251
- this.reactRoot.render(template);
252
- this.requestUpdate();
253
- await this.updateComplete;
254
- return this.container;
255
- }
256
- /**
257
- * 기존 DOM을 라이프 사이클에 맞게 제거합니다.
258
- */
259
- clear() {
260
- if (this.reactRoot) {
261
- this.reactRoot.unmount();
262
- this.reactRoot = void 0;
263
- }
264
- if (this.litRoot) {
265
- this.litRoot.setConnected(false);
266
- this.litRoot = void 0;
267
- }
268
- this.container = document.createElement("div");
269
- this.container.style.width = "100%";
270
- this.container.style.height = "100%";
271
- }
272
- }
273
109
  const styles = css`
274
110
  :host {
275
111
  display: flex;
276
112
  justify-content: center;
277
113
  align-items: center;
278
- width: 100vw;
279
- height: 100vh;
114
+ min-height: 100vh;
115
+ width: 100%;
280
116
  padding: 2rem;
281
- text-align: center;
282
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
283
- color: var(--route-error-color, #333);
284
- background: var(--route-error-background, #fff);
285
- border-radius: var(--route-error-border-radius, 8px);
286
- box-shadow: var(--route-error-box-shadow, 0 2px 8px rgba(0, 0, 0, 0.1));
117
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
118
+ background: var(--route-error-background, linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%));
119
+ color: var(--route-error-color, #2d3748);
120
+ line-height: 1.6;
287
121
  }
288
122
 
289
123
  .container {
290
- max-width: 600px;
124
+ max-width: 520px;
291
125
  margin: 0 auto;
126
+ text-align: center;
127
+ background: var(--route-error-container-bg, rgba(255, 255, 255, 0.95));
128
+ backdrop-filter: blur(10px);
129
+ border-radius: var(--route-error-border-radius, 24px);
130
+ padding: 3rem 2rem;
131
+ box-shadow: var(--route-error-box-shadow, 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04));
132
+ border: 1px solid var(--route-error-border, rgba(255, 255, 255, 0.2));
133
+ animation: slideUp 0.6s ease-out;
292
134
  }
293
135
 
294
- .icon {
295
- font-size: 4rem;
296
- margin-bottom: 1rem;
297
- color: var(--route-error-icon-color, #f44336);
136
+ @keyframes slideUp {
137
+ from {
138
+ opacity: 0;
139
+ transform: translateY(30px);
140
+ }
141
+ to {
142
+ opacity: 1;
143
+ transform: translateY(0);
144
+ }
298
145
  }
299
146
 
300
- .code {
301
- font-size: 2.5rem;
302
- font-weight: bold;
303
- margin-bottom: 0.5rem;
304
- color: var(--route-error-code-color, #f44336);
147
+ .icon {
148
+ font-size: 5rem;
149
+ margin-bottom: 1.5rem;
150
+ animation: bounce 0.8s ease-out 0.2s both;
151
+ filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.1));
305
152
  }
306
153
 
307
- .message {
308
- font-size: 1.25rem;
309
- margin-bottom: 1rem;
310
- color: var(--route-error-message-color, #666);
154
+ @keyframes bounce {
155
+ 0%, 20%, 53%, 80%, 100% {
156
+ transform: translate3d(0, 0, 0);
157
+ }
158
+ 40%, 43% {
159
+ transform: translate3d(0, -10px, 0);
160
+ }
161
+ 70% {
162
+ transform: translate3d(0, -5px, 0);
163
+ }
164
+ 90% {
165
+ transform: translate3d(0, -2px, 0);
166
+ }
311
167
  }
312
168
 
313
- .detail {
314
- font-size: 0.875rem;
315
- margin-bottom: 2rem;
316
- color: var(--route-error-detail-color, #999);
317
- padding: 1rem;
318
- background: var(--route-error-detail-background, #f5f5f5);
319
- border-radius: 4px;
320
- font-family: monospace;
321
- text-align: left;
322
- overflow-x: auto;
169
+ .code {
170
+ font-size: 2rem;
171
+ font-weight: 700;
172
+ margin-bottom: 1rem;
173
+ color: var(--route-error-code-color, #4a5568);
174
+ letter-spacing: -0.025em;
323
175
  }
324
176
 
325
- .path {
326
- font-size: 0.875rem;
327
- margin-bottom: 1rem;
328
- color: var(--route-error-path-color, #999);
329
- font-family: monospace;
177
+ .message {
178
+ font-size: 1.125rem;
179
+ margin-bottom: 2.5rem;
180
+ color: var(--route-error-message-color, #718096);
181
+ font-weight: 400;
182
+ max-width: 400px;
183
+ margin-left: auto;
184
+ margin-right: auto;
330
185
  }
331
186
 
332
187
  .actions {
@@ -337,145 +192,187 @@ const styles = css`
337
192
  }
338
193
 
339
194
  .button {
340
- padding: 0.75rem 1.5rem;
195
+ position: relative;
196
+ padding: 0.875rem 2rem;
341
197
  border: none;
342
- border-radius: 4px;
343
- font-size: 1rem;
198
+ border-radius: 12px;
199
+ font-size: 0.95rem;
200
+ font-weight: 600;
344
201
  cursor: pointer;
345
- transition: all 0.2s ease;
202
+ transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
346
203
  text-decoration: none;
347
- display: inline-block;
204
+ display: inline-flex;
205
+ align-items: center;
206
+ justify-content: center;
207
+ gap: 0.5rem;
348
208
  font-family: inherit;
209
+ min-width: 120px;
210
+ overflow: hidden;
211
+ user-select: none;
212
+ -webkit-tap-highlight-color: transparent;
349
213
  }
350
214
 
351
- .button--primary {
352
- background: var(--route-error-primary-button-bg, #2196f3);
215
+ .button:first-child {
216
+ background: var(--route-error-primary-button-bg, linear-gradient(135deg, #667eea 0%, #764ba2 100%));
353
217
  color: var(--route-error-primary-button-color, white);
218
+ box-shadow: 0 4px 15px 0 rgba(102, 126, 234, 0.4);
354
219
  }
355
220
 
356
- .button--primary:hover {
357
- background: var(--route-error-primary-button-hover-bg, #1976d2);
358
- transform: translateY(-1px);
221
+ .button:first-child:hover {
222
+ transform: translateY(-2px);
223
+ box-shadow: 0 8px 25px 0 rgba(102, 126, 234, 0.5);
359
224
  }
360
225
 
361
- .button--secondary {
362
- background: var(--route-error-secondary-button-bg, #e0e0e0);
363
- color: var(--route-error-secondary-button-color, #333);
226
+ .button:first-child:active {
227
+ transform: translateY(0);
364
228
  }
365
229
 
366
- .button--secondary:hover {
367
- background: var(--route-error-secondary-button-hover-bg, #d0d0d0);
368
- transform: translateY(-1px);
230
+ .button:last-child {
231
+ background: var(--route-error-secondary-button-bg, rgba(255, 255, 255, 0.9));
232
+ color: var(--route-error-secondary-button-color, #4a5568);
233
+ border: 2px solid var(--route-error-secondary-button-border, rgba(74, 85, 104, 0.2));
234
+ backdrop-filter: blur(10px);
369
235
  }
370
236
 
371
- .timestamp {
372
- font-size: 0.75rem;
373
- color: var(--route-error-timestamp-color, #ccc);
374
- margin-top: 2rem;
237
+ .button:last-child:hover {
238
+ background: var(--route-error-secondary-button-hover-bg, rgba(255, 255, 255, 1));
239
+ border-color: var(--route-error-secondary-button-hover-border, rgba(74, 85, 104, 0.4));
240
+ transform: translateY(-1px);
375
241
  }
376
242
 
377
- .metadata {
378
- margin-top: 1rem;
379
- padding: 1rem;
380
- background: var(--route-error-metadata-background, #f9f9f9);
381
- border-radius: 4px;
382
- text-align: left;
243
+ .button:focus-visible {
244
+ outline: 2px solid var(--route-error-focus-color, #667eea);
245
+ outline-offset: 2px;
383
246
  }
384
247
 
385
- .metadata summary {
386
- cursor: pointer;
387
- font-weight: bold;
388
- color: var(--route-error-metadata-title-color, #666);
248
+ .button::before {
249
+ content: '';
250
+ position: absolute;
251
+ top: 0;
252
+ left: -100%;
253
+ width: 100%;
254
+ height: 100%;
255
+ background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
256
+ transition: left 0.5s;
389
257
  }
390
258
 
391
- .metadata pre {
392
- margin: 0.5rem 0 0 0;
393
- font-size: 0.75rem;
394
- color: var(--route-error-metadata-content-color, #999);
395
- overflow-x: auto;
259
+ .button:hover::before {
260
+ left: 100%;
396
261
  }
397
262
 
398
- @media (max-width: 768px) {
263
+ @media (max-width: 640px) {
399
264
  :host {
400
265
  padding: 1rem;
401
266
  }
402
267
 
268
+ .container {
269
+ padding: 2rem 1.5rem;
270
+ border-radius: 20px;
271
+ }
272
+
403
273
  .icon {
404
- font-size: 3rem;
274
+ font-size: 4rem;
405
275
  }
406
276
 
407
277
  .code {
408
- font-size: 2rem;
278
+ font-size: 1.75rem;
409
279
  }
410
280
 
411
281
  .message {
412
282
  font-size: 1rem;
283
+ margin-bottom: 2rem;
413
284
  }
414
285
 
415
286
  .actions {
416
287
  flex-direction: column;
417
288
  align-items: center;
289
+ gap: 0.75rem;
418
290
  }
419
291
 
420
292
  .button {
421
293
  width: 100%;
422
- max-width: 200px;
294
+ max-width: 280px;
295
+ padding: 1rem 2rem;
296
+ }
297
+ }
298
+
299
+ @media (max-width: 480px) {
300
+ .container {
301
+ margin: 1rem;
302
+ padding: 1.5rem 1rem;
303
+ }
304
+
305
+ .icon {
306
+ font-size: 3.5rem;
423
307
  }
424
308
  }
425
309
 
426
310
  @media (prefers-color-scheme: dark) {
427
311
  :host {
428
- color: var(--route-error-dark-color, #e0e0e0);
429
- background: var(--route-error-dark-background, #1e1e1e);
312
+ background: var(--route-error-dark-background, linear-gradient(135deg, #1a202c 0%, #2d3748 100%));
313
+ color: var(--route-error-dark-color, #e2e8f0);
430
314
  }
431
315
 
432
- .message {
433
- color: var(--route-error-dark-message-color, #b0b0b0);
316
+ .container {
317
+ background: var(--route-error-dark-container-bg, rgba(45, 55, 72, 0.95));
318
+ border: 1px solid var(--route-error-dark-border, rgba(255, 255, 255, 0.1));
434
319
  }
435
320
 
436
- .detail {
437
- background: var(--route-error-dark-detail-background, #2a2a2a);
438
- color: var(--route-error-dark-detail-color, #d0d0d0);
321
+ .code {
322
+ color: var(--route-error-dark-code-color, #f7fafc);
439
323
  }
440
324
 
441
- .path {
442
- color: var(--route-error-dark-path-color, #b0b0b0);
325
+ .message {
326
+ color: var(--route-error-dark-message-color, #a0aec0);
443
327
  }
444
328
 
445
- .button--secondary {
446
- background: var(--route-error-dark-secondary-button-bg, #404040);
447
- color: var(--route-error-dark-secondary-button-color, #e0e0e0);
329
+ .button:first-child {
330
+ background: var(--route-error-dark-primary-button-bg, linear-gradient(135deg, #553c9a 0%, #764ba2 100%));
331
+ box-shadow: 0 4px 15px 0 rgba(85, 60, 154, 0.4);
448
332
  }
449
333
 
450
- .button--secondary:hover {
451
- background: var(--route-error-dark-secondary-button-hover-bg, #505050);
334
+ .button:first-child:hover {
335
+ box-shadow: 0 8px 25px 0 rgba(85, 60, 154, 0.5);
452
336
  }
453
337
 
454
- .timestamp {
455
- color: var(--route-error-dark-timestamp-color, #666);
338
+ .button:last-child {
339
+ background: var(--route-error-dark-secondary-button-bg, rgba(74, 85, 104, 0.3));
340
+ color: var(--route-error-dark-secondary-button-color, #e2e8f0);
341
+ border: 2px solid var(--route-error-dark-secondary-button-border, rgba(226, 232, 240, 0.2));
456
342
  }
457
343
 
458
- .metadata {
459
- background: var(--route-error-dark-metadata-background, #2a2a2a);
344
+ .button:last-child:hover {
345
+ background: var(--route-error-dark-secondary-button-hover-bg, rgba(74, 85, 104, 0.5));
346
+ border-color: var(--route-error-dark-secondary-button-hover-border, rgba(226, 232, 240, 0.4));
460
347
  }
348
+ }
461
349
 
462
- .metadata summary {
463
- color: var(--route-error-dark-metadata-title-color, #b0b0b0);
350
+ @media (prefers-reduced-motion: reduce) {
351
+ .container {
352
+ animation: none;
464
353
  }
465
-
466
- .metadata pre {
467
- color: var(--route-error-dark-metadata-content-color, #888);
354
+
355
+ .icon {
356
+ animation: none;
357
+ }
358
+
359
+ .button::before {
360
+ display: none;
361
+ }
362
+
363
+ .button {
364
+ transition: none;
468
365
  }
469
366
  }
470
367
  `;
471
- var __defProp = Object.defineProperty;
368
+ var __defProp$1 = Object.defineProperty;
472
369
  var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
473
- var __decorateClass = (decorators, target, key, kind) => {
370
+ var __decorateClass$1 = (decorators, target, key, kind) => {
474
371
  var result = kind > 1 ? void 0 : kind ? __getOwnPropDesc(target, key) : target;
475
372
  for (var i = decorators.length - 1, decorator; i >= 0; i--)
476
373
  if (decorator = decorators[i])
477
374
  result = (kind ? decorator(target, key, result) : decorator(result)) || result;
478
- if (kind && result) __defProp(target, key, result);
375
+ if (kind && result) __defProp$1(target, key, result);
479
376
  return result;
480
377
  };
481
378
  let ErrorPage = class extends LitElement {
@@ -483,35 +380,25 @@ let ErrorPage = class extends LitElement {
483
380
  const error = this.error || this.getDefaultError();
484
381
  const icon = this.getErrorIcon(error.code);
485
382
  return html`
486
- <div class="container">
383
+ <div class="container" role="alert" aria-live="polite">
487
384
  <div class="icon" aria-hidden="true">${icon}</div>
488
- <div class="code">${error.code}</div>
385
+ <div class="code" aria-label="Error code">${error.code}</div>
489
386
  <div class="message">${error.message}</div>
490
387
 
491
- ${error.path ? html`
492
- <div class="path">
493
- Path: <code>${error.path}</code>
494
- </div>` : nothing}
495
-
496
388
  <div class="actions">
497
389
  <button
498
- class="button button--primary"
499
- @click=${this.handleGoHome}
500
- type="button">
501
- 🏠 Go Home
502
- </button>
503
-
504
- <button
505
- class="button button--secondary"
390
+ class="button"
506
391
  @click=${this.handleGoBack}
507
- type="button">
392
+ title="Go back to previous page"
393
+ aria-label="Go back to previous page">
508
394
  ← Go Back
509
395
  </button>
510
396
 
511
397
  <button
512
- class="button button--secondary"
398
+ class="button"
513
399
  @click=${this.handleRefresh}
514
- type="button">
400
+ title="Refresh the current page"
401
+ aria-label="Refresh the current page">
515
402
  🔄 Refresh
516
403
  </button>
517
404
  </div>
@@ -520,29 +407,27 @@ let ErrorPage = class extends LitElement {
520
407
  }
521
408
  /** 기본 에러 정보 반환 */
522
409
  getDefaultError() {
523
- return {
524
- code: 500,
525
- message: "An unexpected error occurred",
526
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
527
- };
410
+ return new RouteError(500, "Something went wrong. Please try again or contact support if the problem persists.");
528
411
  }
529
412
  /** 에러 코드에 따른 기본 아이콘 반환 */
530
413
  getErrorIcon(code) {
531
- switch (code) {
414
+ const numericCode = typeof code === "string" ? parseInt(code) : code;
415
+ switch (numericCode) {
532
416
  case 404:
533
417
  return "🔍";
534
418
  case 403:
535
419
  return "🔒";
420
+ case 401:
421
+ return "🔑";
422
+ case 429:
423
+ return "⏱️";
424
+ case 503:
425
+ return "🛠️";
536
426
  case 500:
537
- return "⚠️";
538
427
  default:
539
- return "";
428
+ return "⚠️";
540
429
  }
541
430
  }
542
- /** 홈으로 이동 */
543
- handleGoHome() {
544
- window.location.href = "/";
545
- }
546
431
  /** 뒤로가기 */
547
432
  handleGoBack() {
548
433
  window.history.back();
@@ -553,24 +438,12 @@ let ErrorPage = class extends LitElement {
553
438
  }
554
439
  };
555
440
  ErrorPage.styles = styles;
556
- __decorateClass([
441
+ __decorateClass$1([
557
442
  property({ type: Object })
558
443
  ], ErrorPage.prototype, "error", 2);
559
- ErrorPage = __decorateClass([
444
+ ErrorPage = __decorateClass$1([
560
445
  customElement("u-error-page")
561
446
  ], ErrorPage);
562
- customElements.define("u-link", Link);
563
- customElements.define("u-outlet", Outlet);
564
- const ULink = createComponent({
565
- react: React,
566
- tagName: "u-link",
567
- elementClass: Link
568
- });
569
- const UOutlet = createComponent({
570
- react: React,
571
- tagName: "u-outlet",
572
- elementClass: Outlet
573
- });
574
447
  class Router {
575
448
  constructor(config) {
576
449
  this._counter = 0;
@@ -583,11 +456,14 @@ class Router {
583
456
  this._routes = this.setRoutes(config.routes, this._basepath);
584
457
  window.removeEventListener("popstate", this.handlePopstate);
585
458
  window.addEventListener("popstate", this.handlePopstate);
586
- this.handlePopstate();
459
+ this.initiate();
587
460
  }
588
461
  get basepath() {
589
462
  return this._basepath;
590
463
  }
464
+ get routes() {
465
+ return this._routes;
466
+ }
591
467
  get routeInfo() {
592
468
  return this._routeInfo;
593
469
  }
@@ -600,54 +476,45 @@ class Router {
600
476
  this._requestID = requestID;
601
477
  const routeInfo = parseURL(href, this._basepath);
602
478
  if (routeInfo.href === this._routeInfo?.href) return;
603
- if (this._requestID !== requestID) return;
604
- window.dispatchEvent(new RouteStartEvent(routeInfo));
605
479
  try {
480
+ if (this._requestID !== requestID) return;
481
+ window.dispatchEvent(new RouteBeginEvent(routeInfo));
606
482
  const routes = this.getRoutes(routeInfo.pathname);
607
483
  const lastRoute = routes[routes.length - 1];
608
- if (this._requestID !== requestID) return;
609
- routeInfo.params = lastRoute?.pattern?.exec({ pathname: routeInfo.pathname })?.pathname.groups || {};
610
- if (typeof lastRoute?.loader === "function") {
611
- routeInfo.data = await lastRoute.loader(routeInfo);
484
+ if (lastRoute && "path" in lastRoute && lastRoute.path instanceof URLPattern) {
485
+ routeInfo.params = lastRoute.path.exec({ pathname: routeInfo.pathname })?.pathname.groups || {};
612
486
  }
613
- if (this._requestID !== requestID) return;
614
487
  this._routeInfo = routeInfo;
615
488
  window.route = routeInfo;
616
- let outlet = await this.findOutlet(this._rootElement);
489
+ if (this._requestID !== requestID) return;
617
490
  if (routes.length === 0) {
491
+ throw new NotFoundRouteError(routeInfo.href);
492
+ }
493
+ let outlet = this.findOutletOrThrow(this._rootElement);
494
+ let title = void 0;
495
+ for (const route of routes) {
618
496
  if (this._requestID !== requestID) return;
619
- throw new Error(`No route matched for path: ${routeInfo.pathname}`);
497
+ const content = route.render(routeInfo);
498
+ const element = await outlet.renderContent({ id: route.id, content, force: route.force });
499
+ outlet = this.findOutlet(element) || outlet;
500
+ title = route.title || title;
501
+ }
502
+ document.title = title || document.title;
503
+ if (this._requestID !== requestID) return;
504
+ if (routeInfo.href !== window.location.href) {
505
+ window.history.pushState({ basepath: routeInfo.basepath }, "", routeInfo.href);
620
506
  } else {
621
- for (const route of routes) {
622
- if (this._requestID !== requestID) return;
623
- if (route.element) {
624
- const element = await outlet.renderElement({ id: route.id, element: route.element, force: route.force });
625
- outlet = element?.shadowRoot?.querySelector("u-outlet") || outlet;
626
- } else if (route.component) {
627
- const component = await outlet.renderComponent({ id: route.id, component: route.component, force: route.force });
628
- outlet = component?.querySelector("u-outlet") || outlet;
629
- } else {
630
- throw new Error(`Route "${route.path}" has no element or component defined.`);
631
- }
632
- }
633
- if (this._requestID !== requestID) return;
634
- if (routeInfo.href !== window.location.href) {
635
- window.history.pushState({ basepath: routeInfo.basepath }, "", routeInfo.href);
636
- } else {
637
- window.history.replaceState({ basepath: routeInfo.basepath }, "", routeInfo.href);
638
- }
639
- document.title = lastRoute?.title || document.title;
640
- window.dispatchEvent(new RouteEndEvent(routeInfo));
507
+ window.history.replaceState({ basepath: routeInfo.basepath }, "", routeInfo.href);
641
508
  }
509
+ window.dispatchEvent(new RouteDoneEvent(routeInfo));
642
510
  } catch (error) {
643
- const routeError = {
644
- code: error.status || error.code || "UNKNOWN_ERROR",
645
- message: error.message || "An unexpected error occurred",
646
- path: routeInfo?.pathname,
647
- original: error,
648
- timestamp: (/* @__PURE__ */ new Date()).toISOString()
649
- };
511
+ const routeError = new RouteError(
512
+ error.status || error.code || "UNKNOWN_ERROR",
513
+ error.message || "An unexpected error occurred",
514
+ error
515
+ );
650
516
  window.dispatchEvent(new RouteErrorEvent(routeError, routeInfo));
517
+ console.error("Routing error:", error);
651
518
  try {
652
519
  const errorEl = new ErrorPage();
653
520
  errorEl.error = routeError;
@@ -659,24 +526,47 @@ class Router {
659
526
  }
660
527
  }
661
528
  }
529
+ /** 초기 라우팅 처리, TODO: 제거 */
530
+ async initiate() {
531
+ let outlet = await this.findOutlet(this._rootElement);
532
+ while (!outlet && this._counter < 20) {
533
+ await new Promise((resolve) => setTimeout(resolve, 50));
534
+ this._counter++;
535
+ outlet = await this.findOutlet(this._rootElement);
536
+ }
537
+ this._counter = 0;
538
+ this.handlePopstate();
539
+ }
662
540
  /** 라우트를 재설정합니다. */
663
541
  setRoutes(routes, basepath) {
664
542
  for (const route of routes) {
665
543
  route.id ||= getRandomID();
666
- if (route.index)
667
- route.path = "";
668
- if (route.path)
669
- route.path = absolutePath(basepath, route.path);
670
- if (route.path && !route.pattern)
671
- route.pattern = new URLPattern({ pathname: `${route.path}{/}?` });
672
- if (route.children && route.children.length > 0 && route.path) {
673
- route.children = this.setRoutes(route.children, route.path);
544
+ if ("index" in route && route.index) {
545
+ route.path = new URLPattern({ pathname: `${basepath}{/}?` });
546
+ } else if ("path" in route && route.path) {
547
+ if (typeof route.path === "string") {
548
+ const absolutePathStr = absolutePath(basepath, route.path);
549
+ route.path = new URLPattern({ pathname: `${absolutePathStr}{/}?` });
550
+ }
551
+ } else {
552
+ throw new Error('Route must have either "index" or "path" property defined.');
553
+ }
554
+ if (route.children && route.children.length > 0) {
555
+ let childBasepath;
556
+ if ("index" in route) {
557
+ childBasepath = basepath;
558
+ } else {
559
+ if (typeof route.path === "string") {
560
+ childBasepath = absolutePath(basepath, route.path);
561
+ } else {
562
+ childBasepath = route.path.pathname.replace("{/}?", "");
563
+ }
564
+ }
565
+ route.children = this.setRoutes(route.children, childBasepath);
674
566
  route.force ||= false;
675
567
  } else {
676
568
  route.force ||= true;
677
569
  }
678
- if (!route.pattern)
679
- throw new Error(`Route path is required: ${JSON.stringify(route)}`);
680
570
  }
681
571
  return routes;
682
572
  }
@@ -689,40 +579,240 @@ class Router {
689
579
  return [route, ...childRoutes];
690
580
  }
691
581
  }
692
- if (route.pattern?.test({ pathname })) {
582
+ let matches = false;
583
+ if ("index" in route && route.index && route.path) {
584
+ matches = route.path.test({ pathname });
585
+ } else if ("path" in route && route.path instanceof URLPattern) {
586
+ matches = route.path.test({ pathname });
587
+ }
588
+ if (matches) {
693
589
  return [route];
694
590
  }
695
591
  }
696
592
  return [];
697
593
  }
698
594
  /** Outlet 엘리먼트를 찾아 반환합니다. */
699
- async findOutlet(element) {
700
- let outlet = element.querySelector("u-outlet");
701
- if (!outlet && element.shadowRoot) {
702
- if (element instanceof LitElement) {
703
- await element.updateComplete;
704
- }
595
+ findOutlet(element) {
596
+ let outlet = void 0;
597
+ if (element.shadowRoot) {
705
598
  outlet = element.shadowRoot.querySelector("u-outlet");
599
+ if (outlet) return outlet;
600
+ for (const child of Array.from(element.shadowRoot.children)) {
601
+ outlet = this.findOutlet(child);
602
+ if (outlet) return outlet;
603
+ }
604
+ } else {
605
+ outlet = element.querySelector("u-outlet");
606
+ if (outlet) return outlet;
607
+ for (const child of Array.from(element.children)) {
608
+ outlet = this.findOutlet(child);
609
+ if (outlet) return outlet;
610
+ }
706
611
  }
612
+ return void 0;
613
+ }
614
+ /** Outlet 엘리먼트를 찾아 반환합니다. 없으면 에러를 던집니다. */
615
+ findOutletOrThrow(element) {
616
+ const outlet = this.findOutlet(element);
707
617
  if (!outlet) {
708
- if (this._counter > 100)
709
- throw new Error("Outlet element not found.");
710
- this._counter++;
711
- await new Promise((resolve) => setTimeout(resolve, 50));
712
- return this.findOutlet(element);
618
+ throw new Error("No Outlet component found in the root element.");
713
619
  }
714
- this._counter = 0;
715
620
  return outlet;
716
621
  }
717
622
  }
623
+ var __defProp = Object.defineProperty;
624
+ var __decorateClass = (decorators, target, key, kind) => {
625
+ var result = void 0;
626
+ for (var i = decorators.length - 1, decorator; i >= 0; i--)
627
+ if (decorator = decorators[i])
628
+ result = decorator(target, key, result) || result;
629
+ if (result) __defProp(target, key, result);
630
+ return result;
631
+ };
632
+ const EXTERNAL_LINK_PATTERNS = [
633
+ /^http/,
634
+ /^\/\//,
635
+ /^mailto:/,
636
+ /^tel:/,
637
+ /^javascript:/,
638
+ /^ftp:/,
639
+ /^data:/,
640
+ /^ws:/,
641
+ /^wss:/
642
+ ];
643
+ const _Link = class _Link extends LitElement {
644
+ constructor() {
645
+ super(...arguments);
646
+ this.isExternal = false;
647
+ this.anchorHref = "#";
648
+ this.handleMouseDown = (event) => {
649
+ const isNonNavigationClick = event.button === 2 || event.metaKey || event.shiftKey || event.altKey;
650
+ if (event.defaultPrevented || isNonNavigationClick) return;
651
+ event.preventDefault();
652
+ event.stopPropagation();
653
+ const basepath = window.history.state?.basepath || "";
654
+ if (event.button === 1 || event.ctrlKey) {
655
+ window.open(this.anchorHref, "_blank");
656
+ } else if (!this.href) {
657
+ this.dispatchPopstate(basepath, basepath);
658
+ } else if (this.isExternal || this.href.startsWith("/") && !this.href.startsWith(basepath)) {
659
+ window.location.href = this.href;
660
+ } else if (this.href.startsWith("#")) {
661
+ const url = window.location.pathname + window.location.search + this.href;
662
+ this.dispatchHashchange(basepath, url);
663
+ } else if (this.href.startsWith("?")) {
664
+ const url = window.location.pathname + this.href;
665
+ this.dispatchPopstate(basepath, url);
666
+ } else {
667
+ const url = absolutePath(basepath, this.href);
668
+ this.dispatchPopstate(basepath, url);
669
+ }
670
+ };
671
+ this.preventClickEvent = (event) => {
672
+ event.preventDefault();
673
+ event.stopPropagation();
674
+ };
675
+ }
676
+ connectedCallback() {
677
+ super.connectedCallback();
678
+ this.addEventListener("mousedown", this.handleMouseDown);
679
+ }
680
+ disconnectedCallback() {
681
+ this.removeEventListener("mousedown", this.handleMouseDown);
682
+ super.disconnectedCallback();
683
+ }
684
+ async updated(changedProperties) {
685
+ super.updated(changedProperties);
686
+ await this.updateComplete;
687
+ if (changedProperties.has("href")) {
688
+ this.isExternal = this.checkExternalLink(this.href || "");
689
+ this.anchorHref = this.getAnchorHref(this.href);
690
+ }
691
+ }
692
+ render() {
693
+ return html`
694
+ <a href=${this.anchorHref} @click=${this.preventClickEvent}>
695
+ <slot></slot>
696
+ </a>
697
+ `;
698
+ }
699
+ /** 클라이언트 라우팅을 위해 popstate 이벤트를 발생시킵니다. */
700
+ dispatchPopstate(basepath, url) {
701
+ window.history.pushState({ basepath }, "", url);
702
+ window.dispatchEvent(new PopStateEvent("popstate"));
703
+ }
704
+ /** 클라이언트 라우팅을 위해 hashchange 이벤트를 발생시킵니다. */
705
+ dispatchHashchange(basepath, url) {
706
+ window.history.pushState({ basepath }, "", url);
707
+ window.dispatchEvent(new HashChangeEvent("hashchange"));
708
+ }
709
+ /** 외부 링크인지 확인합니다. */
710
+ checkExternalLink(href) {
711
+ return EXTERNAL_LINK_PATTERNS.some((pattern) => pattern.test(href));
712
+ }
713
+ /** a 태그의 href 값을 계산합니다. */
714
+ getAnchorHref(href) {
715
+ const basepath = window.history.state?.basepath || "";
716
+ if (!href) {
717
+ return window.location.origin + basepath;
718
+ }
719
+ if (this.isExternal || href.startsWith("/") || href.startsWith("#") || href.startsWith("?")) {
720
+ return href;
721
+ }
722
+ return absolutePath(basepath, href);
723
+ }
724
+ };
725
+ _Link.styles = css`
726
+ :host {
727
+ display: inline-flex;
728
+ cursor: pointer;
729
+ }
730
+
731
+ a {
732
+ display: contents;
733
+ text-decoration: none;
734
+ color: inherit;
735
+ }
736
+ `;
737
+ let Link = _Link;
738
+ __decorateClass([
739
+ state()
740
+ ], Link.prototype, "anchorHref");
741
+ __decorateClass([
742
+ property({ type: String })
743
+ ], Link.prototype, "href");
744
+ class Outlet extends LitElement {
745
+ /** 쉐도우를 사용하지 않고, 직접 렌더링합니다. */
746
+ createRenderRoot() {
747
+ return this;
748
+ }
749
+ render() {
750
+ return html`${this.container}`;
751
+ }
752
+ /**
753
+ * render 함수의 결과를 렌더링합니다.
754
+ * - HTMLElement, ReactElement, TemplateResult를 모두 처리할 수 있습니다.
755
+ */
756
+ async renderContent({ id, content, force }) {
757
+ if (this.routeId === id && force === false && this.container) {
758
+ return this.container;
759
+ }
760
+ this.routeId = id;
761
+ this.clear();
762
+ if (!this.container) {
763
+ throw new Error("DOM이 초기화되지 않았습니다.");
764
+ }
765
+ if (content instanceof HTMLElement) {
766
+ this.container.appendChild(content);
767
+ } else if ("_$litType$" in content) {
768
+ this.content = render(content, this.container);
769
+ } else if ("$$typeof" in content) {
770
+ this.content = createRoot(this.container);
771
+ this.content.render(content);
772
+ } else {
773
+ throw new Error("not supported content type for Outlet rendering.");
774
+ }
775
+ this.requestUpdate();
776
+ await this.updateComplete;
777
+ return this.container;
778
+ }
779
+ /**
780
+ * 기존 DOM을 라이프 사이클에 맞게 제거합니다.
781
+ */
782
+ clear() {
783
+ if (this.content) {
784
+ if ("unmount" in this.content) {
785
+ this.content.unmount();
786
+ }
787
+ if ("setConnected" in this.content) {
788
+ this.content.setConnected(false);
789
+ }
790
+ this.content = void 0;
791
+ }
792
+ this.container = document.createElement("div");
793
+ this.container.style.display = "contents";
794
+ }
795
+ }
796
+ customElements.define("u-link", Link);
797
+ customElements.define("u-outlet", Outlet);
798
+ const ULink = createComponent({
799
+ react: React,
800
+ tagName: "u-link",
801
+ elementClass: Link
802
+ });
803
+ const UOutlet = createComponent({
804
+ react: React,
805
+ tagName: "u-outlet",
806
+ elementClass: Outlet
807
+ });
718
808
  export {
719
- ErrorPage,
720
809
  Link,
810
+ NotFoundRouteError,
721
811
  Outlet,
722
- RouteEndEvent,
812
+ RouteBeginEvent,
813
+ RouteDoneEvent,
814
+ RouteError,
723
815
  RouteErrorEvent,
724
- RouteEvent,
725
- RouteStartEvent,
726
816
  Router,
727
817
  ULink,
728
818
  UOutlet