@iyulab/modern-app 0.46.0 → 0.47.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/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.47.0] - 2026-10-08
4
+
5
+ ### Changed
6
+
7
+ - **An app with no `fallback` draws a failed route as `<u-empty-state>`**, inside the shell: a route `enter` guard's
8
+ refusal (403) as `no-access`, an address with no route (404) as the new `not-found`, and any other failure (the
9
+ route could not be loaded or rendered) as `error`, titled "Couldn’t open this page" with the error message as its
10
+ description. Before, the router's own error page (a large status code) was drawn — outside this package's look, and
11
+ a refusal looked like an outage. A `fallback` you pass is used as before and replaces the default entirely.
12
+
13
+ ### Added
14
+
15
+ - **`u-empty-state variant="not-found"`** — nothing at this address; the next step is checking the address or going
16
+ back. Not announced as an alert. Default wording in English and Korean (`notFoundTitle`/`notFoundDescription` in
17
+ `modernAppLocale`), and `routeErrorTitle` for the default fallback's error screen.
18
+
19
+ ### Documentation
20
+
21
+ - The empty-state reference's blocked-routes example passes a complete app config (the published 0.46.0 skill
22
+ document left out the required `layout`).
23
+
3
24
  ## [0.46.0] - 2026-10-08
4
25
 
5
26
  ### Added
package/README.md CHANGED
@@ -38,13 +38,14 @@ await app.load({
38
38
  { path: 'users', render: () => html`<users-page></users-page>` },
39
39
  { path: 'users/:id', render: (ctx) => html`<user-detail .userId=${ctx.params.id}></user-detail>` },
40
40
  ],
41
- fallback: {
42
- render: (ctx) => html`<error-page .error=${ctx.error}></error-page>`,
43
- },
44
41
  theme: { default: 'system' },
45
42
  });
46
43
  ```
47
44
 
45
+ A route that fails is drawn inside the shell as an empty state: a guard's refusal (403) as
46
+ `<u-empty-state variant="no-access">`, an unknown address (404) as `variant="not-found"`, anything else as
47
+ `variant="error"` with the error message. Pass `fallback: { render: (ctx) => … }` to draw your own instead.
48
+
48
49
  ### Icons
49
50
 
50
51
  A menu item's `icon` is a name resolved by `<u-icon>` from `@iyulab/components`:
package/dist/App.js CHANGED
@@ -1,27 +1,28 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
- import t from "i18next";
3
- import { Router as n } from "@iyulab/router";
4
- import { setDefaultBaseUrl as r } from "@iyulab/components/dist/utilities/icons.js";
5
- import { Theme as i } from "@iyulab/components/dist/utilities/Theme.js";
6
- import { Toast as a } from "@iyulab/components/dist/utilities/Toast.js";
7
- import { Locale as o } from "@iyulab/components/dist/utilities/Locale.js";
2
+ import { defaultFallback as t } from "./internals/default-fallback.js";
3
+ import n from "i18next";
4
+ import { Router as r } from "@iyulab/router";
5
+ import { setDefaultBaseUrl as i } from "@iyulab/components/dist/utilities/icons.js";
6
+ import { Theme as a } from "@iyulab/components/dist/utilities/Theme.js";
7
+ import { Toast as o } from "@iyulab/components/dist/utilities/Toast.js";
8
+ import { Locale as s } from "@iyulab/components/dist/utilities/Locale.js";
8
9
  //#region src/App.ts
9
- var s = "\n:where(body) { margin: 0; }\n@media screen {\n :where(body) { width: 100vw; height: 100vh; }\n}\n", c;
10
- function l() {
11
- if (!c) {
10
+ var c = "\n:where(body) { margin: 0; }\n@media screen {\n :where(body) { width: 100vw; height: 100vh; }\n}\n", l;
11
+ function u() {
12
+ if (!l) {
12
13
  if ("adoptedStyleSheets" in document && typeof CSSStyleSheet < "u" && typeof CSSStyleSheet.prototype.replaceSync == "function") {
13
14
  let e = new CSSStyleSheet();
14
- e.replaceSync(s), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], c = e;
15
+ e.replaceSync(c), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], l = e;
15
16
  } else {
16
17
  let e = document.createElement("style");
17
- e.textContent = s, document.head.prepend(e), c = e;
18
+ e.textContent = c, document.head.prepend(e), l = e;
18
19
  }
19
20
  }
20
21
  }
21
- var u = class s {
22
+ var d = class c {
22
23
  constructor() {}
23
24
  static get instance() {
24
- return s._instance ||= new s(), s._instance;
25
+ return c._instance ||= new c(), c._instance;
25
26
  }
26
27
  get config() {
27
28
  return this._config;
@@ -36,22 +37,22 @@ var u = class s {
36
37
  return this._user;
37
38
  }
38
39
  get theme() {
39
- return i;
40
+ return a;
40
41
  }
41
42
  get i18n() {
42
- return t;
43
+ return n;
43
44
  }
44
- async load(a) {
45
- if (this.unload(), this._config = a, await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
46
- for (let e of a.i18n.plugins || []) t.use(e);
47
- await t.init(a.i18n), this.syncLanguage();
45
+ async load(o) {
46
+ if (this.unload(), this._config = o, await a.init(o.theme), o.iconBasepath && i(o.iconBasepath), o.i18n) {
47
+ for (let e of o.i18n.plugins || []) n.use(e);
48
+ await n.init(o.i18n), this.syncLanguage();
48
49
  }
49
- if (a.auth) {
50
- let e = a.root || document.body, t = () => {
51
- this.load(a);
50
+ if (o.auth) {
51
+ let e = o.root || document.body, t = () => {
52
+ this.load(o);
52
53
  }, n;
53
54
  try {
54
- n = await a.auth.me();
55
+ n = await o.auth.me();
55
56
  } catch (e) {
56
57
  n = {
57
58
  status: "unknown",
@@ -59,8 +60,8 @@ var u = class s {
59
60
  };
60
61
  }
61
62
  if (n?.status === "unknown") {
62
- if (!a.auth.renderUnavailable) throw n.error;
63
- let r = a.auth.renderUnavailable({
63
+ if (!o.auth.renderUnavailable) throw n.error;
64
+ let r = o.auth.renderUnavailable({
64
65
  root: e,
65
66
  error: n.error,
66
67
  retry: t
@@ -69,7 +70,7 @@ var u = class s {
69
70
  return;
70
71
  }
71
72
  if (n?.status === "anonymous") {
72
- let n = a.auth.renderLogin({
73
+ let n = o.auth.renderLogin({
73
74
  root: e,
74
75
  onSuccess: t
75
76
  });
@@ -77,32 +78,32 @@ var u = class s {
77
78
  return;
78
79
  }
79
80
  if (n?.status !== "authenticated") throw TypeError("auth.me() must return { status: 'authenticated', user } | { status: 'anonymous' } | { status: 'unknown', error }");
80
- this._user = n.user, await a.auth.onAuthenticated?.(n.user);
81
+ this._user = n.user, await o.auth.onAuthenticated?.(n.user);
81
82
  }
82
- let o = a.root || document.body;
83
- this._layout = await this.createLayout(o, a.layout), this._screen = new e({
84
- element: o,
85
- breakpoints: a.layout.breakpoints || [768, 1024]
83
+ let s = o.root || document.body;
84
+ this._layout = await this.createLayout(s, o.layout), this._screen = new e({
85
+ element: s,
86
+ breakpoints: o.layout.breakpoints || [768, 1024]
86
87
  });
87
- let s = document.createElement("u-outlet");
88
- this._layout.appendChild(s), this._router = new n({
88
+ let c = document.createElement("u-outlet");
89
+ this._layout.appendChild(c), this._router = new r({
89
90
  root: this._layout,
90
- basepath: a.basepath,
91
- mode: a.routerMode,
92
- routes: a.routes,
93
- fallback: a.fallback,
94
- enter: a.enter,
95
- initialLoad: a.initialLoad,
96
- useIntercept: a.useIntercept
91
+ basepath: o.basepath,
92
+ mode: o.routerMode,
93
+ routes: o.routes,
94
+ fallback: o.fallback ?? t,
95
+ enter: o.enter,
96
+ initialLoad: o.initialLoad,
97
+ useIntercept: o.useIntercept
97
98
  });
98
99
  }
99
100
  syncLanguage() {
100
101
  let e = (e) => {
101
- e && e !== "cimode" && (o.set(e), typeof document < "u" && (document.documentElement.lang = e));
102
+ e && e !== "cimode" && (s.set(e), typeof document < "u" && (document.documentElement.lang = e));
102
103
  };
103
- e(t.resolvedLanguage ?? t.language);
104
- let n = (n) => e(t.resolvedLanguage ?? n);
105
- t.on("languageChanged", n), this._languageSync = () => t.off("languageChanged", n);
104
+ e(n.resolvedLanguage ?? n.language);
105
+ let t = (t) => e(n.resolvedLanguage ?? t);
106
+ n.on("languageChanged", t), this._languageSync = () => n.off("languageChanged", t);
106
107
  }
107
108
  unload() {
108
109
  this._languageSync?.(), this._languageSync = void 0, this._gateTeardown &&= (this._gateTeardown(), void 0), this._user = void 0, this._screen &&= (this._screen.destroy(), void 0), this._layout &&= (this._layout.remove(), void 0), this._router &&= (this._router.destroy(), void 0), this._config &&= void 0;
@@ -117,22 +118,22 @@ var u = class s {
117
118
  t === void 0 ? delete r[e] : r[e] = t, n.counts = r;
118
119
  }
119
120
  async notice(e, t) {
120
- await a.notice(e, { ...t });
121
+ await o.notice(e, { ...t });
121
122
  }
122
123
  async info(e, t) {
123
- await a.info(e, { ...t });
124
+ await o.info(e, { ...t });
124
125
  }
125
126
  async warning(e, t) {
126
- await a.warning(e, { ...t });
127
+ await o.warning(e, { ...t });
127
128
  }
128
129
  async success(e, t) {
129
- await a.success(e, { ...t });
130
+ await o.success(e, { ...t });
130
131
  }
131
132
  async error(e, t) {
132
- await a.error(e, { ...t });
133
+ await o.error(e, { ...t });
133
134
  }
134
135
  async createLayout(e, t) {
135
- e === document.body && l();
136
+ e === document.body && u();
136
137
  let n;
137
138
  if (t.type === "sidebar") {
138
139
  let { SidebarLayout: e } = await import("./layouts/SidebarLayout.js"), r = new e();
@@ -142,4 +143,4 @@ var u = class s {
142
143
  }
143
144
  }.instance;
144
145
  //#endregion
145
- export { u as app };
146
+ export { d as app };
@@ -1,7 +1,7 @@
1
1
  import { StyledElement } from '../internals/StyledElement.js';
2
2
  type ElementParts = 'host' | 'icon' | 'message' | 'title' | 'description' | 'actions';
3
3
  /** 빈 상태가 전하는 사실 — 다음 행동이 갈린다(`EmptyState.variant` 참조). */
4
- export type EmptyStateVariant = 'no-data' | 'no-results' | 'error' | 'no-access';
4
+ export type EmptyStateVariant = 'no-data' | 'no-results' | 'error' | 'no-access' | 'not-found';
5
5
  /**
6
6
  * 빈 상태 — 목록·검색 결과가 비었을 때.
7
7
  *
@@ -24,8 +24,14 @@ export type EmptyStateVariant = 'no-data' | 'no-results' | 'error' | 'no-access'
24
24
  *
25
25
  * <!-- 라우트 가드가 막았다(403) — 장애가 아니라 권한의 사실이다. -->
26
26
  * <u-empty-state variant="no-access"></u-empty-state>
27
+ *
28
+ * <!-- 주소가 아무것도 가리키지 않는다(404) — 다음 행동은 주소 확인 · 돌아가기. -->
29
+ * <u-empty-state variant="not-found"></u-empty-state>
27
30
  * ```
28
31
  *
32
+ * `app.load()` 에 `fallback` 을 주지 않으면 라우팅 실패가 이 요소로 그려진다 — 403 `no-access` · 404 `not-found` ·
33
+ * 그 밖 `error`(오류 메시지가 설명).
34
+ *
29
35
  * ⚠**기본 문구는 영어다** — 이 패키지는 범용 층이라 특정 언어를 기본값으로 가질 수 없다.
30
36
  * 한국어 표는 내장돼 있고(언어는 `Locale.set()`), 다른 언어는 `modernAppLocale.register(lang, …)` 로 소비자가 등록한다. 화면별로 덮으려면
31
37
  * `title`·`description` 을 준다.
@@ -38,9 +44,10 @@ export declare class EmptyState extends StyledElement<ElementParts> {
38
44
  static styles: import('lit').CSSResultGroup[];
39
45
  /**
40
46
  * `no-data` = 아직 없음 / `no-results` = 조건에 맞는 것이 없음 / `error` = 불러오지 못함 /
41
- * `no-access` = 있지만 이 사용자에게 보여 줄 수 없음(라우트 가드의 거부 · 403).
42
- * 넷은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도 · 권한 요청) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
43
- * `no-access` 는 알리지 않는다 — 장애가 아니라 화면의 내용이고, 라우트 완료 때 본문으로 가는 포커스가 그것을 읽게 한다.
47
+ * `no-access` = 있지만 이 사용자에게 보여 줄 수 없음(라우트 가드의 거부 · 403) /
48
+ * `not-found` = 주소가 가리키는 화면이 없음(404).
49
+ * 다섯은 다음 행동이 다르다(만들기 · 조건 바꾸기 · 다시 시도 · 권한 요청 · 주소 확인) — `error` 의 제목은 보조기기에 알린다(`role="alert"`).
50
+ * `no-access`·`not-found` 는 알리지 않는다 — 장애가 아니라 화면의 내용이고, 라우트 완료 때 본문으로 가는 포커스가 그것을 읽게 한다.
44
51
  */
45
52
  variant: EmptyStateVariant;
46
53
  /** 제목. 비우면 variant 기본 문구. */
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./EmptyState.styles.js";
7
7
  import { html as o, nothing as s } from "lit";
8
8
  import { customElement as c, property as l, state as u } from "lit/decorators.js";
@@ -15,7 +15,7 @@ var d = class extends n {
15
15
  this.styles = [super.styles, a];
16
16
  }
17
17
  get defaults() {
18
- let e = r(this.locale || void 0);
18
+ let e = i(this.locale || void 0);
19
19
  switch (this.variant) {
20
20
  case "error": return {
21
21
  icon: "⚠️",
@@ -27,6 +27,11 @@ var d = class extends n {
27
27
  title: e.noAccessTitle,
28
28
  description: e.noAccessDescription
29
29
  };
30
+ case "not-found": return {
31
+ icon: "🧭",
32
+ title: e.notFoundTitle,
33
+ description: e.notFoundDescription
34
+ };
30
35
  case "no-results": return {
31
36
  icon: "🔍",
32
37
  title: e.noResultsTitle,
@@ -53,7 +58,7 @@ var d = class extends n {
53
58
  </div>
54
59
  <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
55
60
  <slot name="actions"
56
- @slotchange=${(e) => this.hasActions = i(e.target)}
61
+ @slotchange=${(e) => this.hasActions = r(e.target)}
57
62
  ></slot>
58
63
  </div>
59
64
  `;
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./MasterDetailLayout.styles.js";
7
7
  import { html as o } from "lit";
8
8
  import { customElement as s, property as c, state as l } from "lit/decorators.js";
@@ -14,7 +14,7 @@ var u = class extends n {
14
14
  super(...e), this.masterSize = "22rem", this.overlayBreakpoint = 760, this.locale = "", this.hasDetail = !1, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.handleDetailClose = () => {
15
15
  this.fire("detail-close", { cancelable: !1 });
16
16
  }, this.handleDetailSlotChange = (e) => {
17
- this.hasDetail = i(e.target);
17
+ this.hasDetail = r(e.target);
18
18
  };
19
19
  }
20
20
  static {
@@ -40,7 +40,7 @@ var u = class extends n {
40
40
  <div class="divider ${this.hasDetail ? "" : "empty"}" part="divider"></div>
41
41
  <div class="detail ${this.hasDetail ? "" : "empty"}" part="detail">
42
42
  <u-button class="detail-close" part="detail-close" appearance="plain"
43
- aria-label=${r(this.locale || void 0).detailClose}
43
+ aria-label=${i(this.locale || void 0).detailClose}
44
44
  @click=${this.handleDetailClose}
45
45
  >
46
46
  <u-icon lib="internal" name="x"></u-icon>
@@ -1,8 +1,8 @@
1
1
  import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
2
  import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
3
  import { StyledElement as n } from "../internals/StyledElement.js";
4
- import { getLocaleStrings as r } from "../internals/locale.js";
5
- import { slotHasContent as i } from "../internals/slotted.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
6
  import { styles as a } from "./PageHeader.styles.js";
7
7
  import { html as o, nothing as s } from "lit";
8
8
  import { customElement as c, property as l, state as u } from "lit/decorators.js";
@@ -16,18 +16,18 @@ var d = class extends n {
16
16
  }
17
17
  render() {
18
18
  return o`
19
- ${this.back ? o`<a class="back" part="back" href=${this.back}>${this.backLabel || r(this.locale || void 0).back}</a>` : s}
19
+ ${this.back ? o`<a class="back" part="back" href=${this.back}>${this.backLabel || i(this.locale || void 0).back}</a>` : s}
20
20
  <div class="heading" part="heading">
21
21
  <div class="eyebrow ${this.eyebrow || this.hasEyebrow ? "" : "empty"}" part="eyebrow">
22
22
  <slot name="eyebrow"
23
- @slotchange=${(e) => this.hasEyebrow = i(e.target)}
23
+ @slotchange=${(e) => this.hasEyebrow = r(e.target)}
24
24
  >${this.eyebrow ?? s}</slot>
25
25
  </div>
26
26
  <div class="title-row">
27
27
  <h1 class="title" part="title">${this.title}</h1>
28
28
  <span class="status ${this.hasStatus ? "" : "empty"}" part="status">
29
29
  <slot name="status"
30
- @slotchange=${(e) => this.hasStatus = i(e.target)}
30
+ @slotchange=${(e) => this.hasStatus = r(e.target)}
31
31
  ></slot>
32
32
  </span>
33
33
  </div>
@@ -35,7 +35,7 @@ var d = class extends n {
35
35
  </div>
36
36
  <div class="actions ${this.hasActions ? "" : "empty"}" part="actions">
37
37
  <slot name="actions"
38
- @slotchange=${(e) => this.hasActions = i(e.target)}
38
+ @slotchange=${(e) => this.hasActions = r(e.target)}
39
39
  ></slot>
40
40
  </div>
41
41
  `;
package/dist/index.js CHANGED
@@ -1,12 +1,12 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
- import { app as t } from "./App.js";
3
- import { filterSidebarItems as n } from "./layouts/filterSidebarItems.js";
4
- import { getDefaultLocale as r, getLocaleStrings as i, modernAppLocale as a, registerLocale as o, setDefaultLocale as s } from "./internals/locale.js";
5
- import { PageHeader as c } from "./components/PageHeader.js";
6
- import { GroupBox as l } from "./components/GroupBox.js";
7
- import { InfoSection as u } from "./components/InfoSection.js";
8
- import { InfoField as d, isBlank as f } from "./components/InfoField.js";
9
- import { EmptyState as p } from "./components/EmptyState.js";
2
+ import { getDefaultLocale as t, getLocaleStrings as n, modernAppLocale as r, registerLocale as i, setDefaultLocale as a } from "./internals/locale.js";
3
+ import { EmptyState as o } from "./components/EmptyState.js";
4
+ import { app as s } from "./App.js";
5
+ import { filterSidebarItems as c } from "./layouts/filterSidebarItems.js";
6
+ import { PageHeader as l } from "./components/PageHeader.js";
7
+ import { GroupBox as u } from "./components/GroupBox.js";
8
+ import { InfoSection as d } from "./components/InfoSection.js";
9
+ import { InfoField as f, isBlank as p } from "./components/InfoField.js";
10
10
  import { TimelineItem as m } from "./components/TimelineItem.js";
11
11
  import { Timeline as h } from "./components/Timeline.js";
12
12
  import { ActionBar as g } from "./components/ActionBar.js";
@@ -15,6 +15,6 @@ import { Wizard as v } from "./components/Wizard.js";
15
15
  import { translate as y } from "./translate.js";
16
16
  import { AccessDeniedError as b, ContentLoadError as x, ContentRenderError as S, NotFoundError as C, OutletMissingError as w, RouteError as T } from "@iyulab/router";
17
17
  //#region src/index.ts
18
- var E = t;
18
+ var E = s;
19
19
  //#endregion
20
- export { b as AccessDeniedError, g as ActionBar, x as ContentLoadError, S as ContentRenderError, p as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, _ as MasterDetailLayout, C as NotFoundError, w as OutletMissingError, c as PageHeader, T as RouteError, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, t as app, E as default, n as filterSidebarItems, r as getDefaultLocale, i as getLocaleStrings, f as isBlank, a as modernAppLocale, o as registerLocale, s as setDefaultLocale, y as translate };
20
+ export { b as AccessDeniedError, g as ActionBar, x as ContentLoadError, S as ContentRenderError, o as EmptyState, u as GroupBox, f as InfoField, d as InfoSection, _ as MasterDetailLayout, C as NotFoundError, w as OutletMissingError, l as PageHeader, T as RouteError, e as ScreenObserver, h as Timeline, m as TimelineItem, v as Wizard, s as app, E as default, c as filterSidebarItems, t as getDefaultLocale, n as getLocaleStrings, p as isBlank, r as modernAppLocale, i as registerLocale, a as setDefaultLocale, y as translate };
@@ -0,0 +1,16 @@
1
+ import { FallbackRouteConfig, RouteError } from '@iyulab/router';
2
+ import { EmptyStateVariant } from '../components/EmptyState.js';
3
+ /**
4
+ * `app.load()` 에 `fallback` 을 주지 않은 앱의 라우팅 실패 화면 — 앱 프레임워크의 빈 상태 어휘로 그린다.
5
+ *
6
+ * 종전에는 router 의 `UErrorPage`(큰 상태 코드 화면)가 그려졌다. 셸·빈 상태 어휘를 가진 프레임워크에서 오류만
7
+ * 남의 외형이었고, 권한 거부(403)도 «장애» 처럼 보였다.
8
+ *
9
+ * - 403 → `no-access`(라우트 가드의 거부) · 404 → `not-found` · 그 밖 → `error`(오류 메시지가 설명).
10
+ * - 판정은 클래스가 아니라 **코드**로 한다 — `AccessDeniedError`·`NotFoundError` 가 403·404 를 싣고, 라우트가 던진
11
+ * `{ status: 404 }` 류도 router 가 같은 코드의 `RouteError` 로 감싼다. 진단 코드(`OUTLET_MISSING` 등)는 화면에
12
+ * 코드로 내지 않고 메시지를 설명으로 쓴다.
13
+ * - 소비자가 `fallback` 을 주면 그것이 그대로 이긴다(이 모듈은 쓰이지 않는다).
14
+ */
15
+ export declare function variantOf(error: Pick<RouteError, 'code'>): EmptyStateVariant;
16
+ export declare const defaultFallback: FallbackRouteConfig;
@@ -0,0 +1,16 @@
1
+ import { getLocaleStrings as e } from "./locale.js";
2
+ import { EmptyState as t } from "../components/EmptyState.js";
3
+ //#region src/internals/default-fallback.ts
4
+ function n(e) {
5
+ switch (String(e.code)) {
6
+ case "403": return "no-access";
7
+ case "404": return "not-found";
8
+ default: return "error";
9
+ }
10
+ }
11
+ var r = { render: (r) => {
12
+ let i = new t();
13
+ return i.variant = n(r.error), i.variant === "error" && (i.title = e().routeErrorTitle, i.description = r.error.message), i;
14
+ } };
15
+ //#endregion
16
+ export { r as defaultFallback, n as variantOf };
@@ -13,6 +13,12 @@ export interface ModernAppLocaleStrings {
13
13
  /** Empty state — the screen exists but this user may not see it (`variant="no-access"` — a route guard said no). */
14
14
  noAccessTitle: string;
15
15
  noAccessDescription: string;
16
+ /** Empty state — the address points at nothing (`variant="not-found"` — the router found no route, a 404). */
17
+ notFoundTitle: string;
18
+ notFoundDescription: string;
19
+ /** Default route fallback — the title when a route failed for another reason (load or render failure). The
20
+ * `error` variant's own default title speaks of a list. */
21
+ routeErrorTitle: string;
16
22
  /** Accessible label for an overlay-mode close button (master-detail layout, sidebar layout overlay). */
17
23
  detailClose: string;
18
24
  /** Sidebar layout — accessible label for the mobile-header menu toggle button. */
@@ -10,6 +10,9 @@ var t = {
10
10
  errorDescription: "Something went wrong while loading. Try again.",
11
11
  noAccessTitle: "You don’t have access",
12
12
  noAccessDescription: "Ask an administrator for access if you need it.",
13
+ notFoundTitle: "Page not found",
14
+ notFoundDescription: "Check the address, or go back to the previous screen.",
15
+ routeErrorTitle: "Couldn’t open this page",
13
16
  detailClose: "Close",
14
17
  toggleMobileMenu: "Toggle menu",
15
18
  toggleSidebar: "Toggle sidebar",
@@ -30,6 +33,9 @@ n.register("en", t), n.register("ko", {
30
33
  errorDescription: "불러오는 중 문제가 생겼습니다. 다시 시도해 주세요.",
31
34
  noAccessTitle: "접근 권한이 없습니다",
32
35
  noAccessDescription: "필요하면 관리자에게 권한을 요청하세요.",
36
+ notFoundTitle: "페이지를 찾을 수 없습니다",
37
+ notFoundDescription: "주소를 확인하거나 이전 화면으로 돌아가 주세요.",
38
+ routeErrorTitle: "화면을 열지 못했습니다",
33
39
  detailClose: "닫기",
34
40
  toggleMobileMenu: "메뉴 열고 닫기",
35
41
  toggleSidebar: "사이드바 열고 닫기",
@@ -68,6 +74,9 @@ function l(e) {
68
74
  errorDescription: t("errorDescription"),
69
75
  noAccessTitle: t("noAccessTitle"),
70
76
  noAccessDescription: t("noAccessDescription"),
77
+ notFoundTitle: t("notFoundTitle"),
78
+ notFoundDescription: t("notFoundDescription"),
79
+ routeErrorTitle: t("routeErrorTitle"),
71
80
  detailClose: t("detailClose"),
72
81
  toggleMobileMenu: t("toggleMobileMenu"),
73
82
  toggleSidebar: t("toggleSidebar"),
@@ -1,10 +1,10 @@
1
- import { app as e } from "../App.js";
2
- import { filterSidebarItems as t } from "./filterSidebarItems.js";
3
- import n from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
4
- import r from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
5
- import { StyledElement as i } from "../internals/StyledElement.js";
6
- import { getLocaleStrings as a } from "../internals/locale.js";
7
- import { slotHasContent as o } from "../internals/slotted.js";
1
+ import e from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorateMetadata.js";
2
+ import t from "../_virtual/_@oxc-project_runtime@0.153.0/helpers/esm/decorate.js";
3
+ import { StyledElement as n } from "../internals/StyledElement.js";
4
+ import { slotHasContent as r } from "../internals/slotted.js";
5
+ import { getLocaleStrings as i } from "../internals/locale.js";
6
+ import { app as a } from "../App.js";
7
+ import { filterSidebarItems as o } from "./filterSidebarItems.js";
8
8
  import "../components/SidebarSection.js";
9
9
  import { DEFAULT_NAV_ICON as s } from "../internals/nav-icon.js";
10
10
  import "../components/SidebarLink.js";
@@ -74,14 +74,14 @@ function j(e, t) {
74
74
  }
75
75
  return !1;
76
76
  }
77
- var M = class extends i {
77
+ var M = class extends n {
78
78
  static {
79
79
  S = this;
80
80
  }
81
- constructor(...t) {
82
- super(...t), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.routeLoading = !1, this.mainTabStop = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.currentPath = null, this.unsizedWarnKey = `unsized:${++w}`, this.isMatchedLink = (e) => this.currentPath === null || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.currentPath, window.location.origin)), this.handleToggleButtonClick = () => {
83
- let t = this.screenSize ?? e.screen ?? "large";
84
- t === "large" ? this.state = this.state === "default" ? "slim" : "default" : t === "medium" ? this.state = this.state === "slim" ? "modal" : "slim" : t === "small" ? this.state = this.state === "mobile" ? "mobile-open" : "mobile" : console.warn("Unknown screen size:", t);
81
+ constructor(...e) {
82
+ super(...e), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.routeLoading = !1, this.mainTabStop = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.currentPath = null, this.unsizedWarnKey = `unsized:${++w}`, this.isMatchedLink = (e) => this.currentPath === null || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.currentPath, window.location.origin)), this.handleToggleButtonClick = () => {
83
+ let e = this.screenSize ?? a.screen ?? "large";
84
+ e === "large" ? this.state = this.state === "default" ? "slim" : "default" : e === "medium" ? this.state = this.state === "slim" ? "modal" : "slim" : e === "small" ? this.state = this.state === "mobile" ? "mobile-open" : "mobile" : console.warn("Unknown screen size:", e);
85
85
  }, this.handleBackdropClick = () => {
86
86
  this.state = "slim";
87
87
  }, this.handleRouteBegin = (e) => {
@@ -110,14 +110,14 @@ var M = class extends i {
110
110
  this.routeLoading = !1, this.progressBarEl.status = "default";
111
111
  }, 300);
112
112
  }, this.handleOverlaySlotChange = (e) => {
113
- let t = o(e.target);
113
+ let t = r(e.target);
114
114
  if (t && !this.hasOverlay) {
115
115
  let e = A();
116
116
  this.overlayTrigger = e && e !== document.body ? e : null;
117
117
  }
118
118
  this.hasOverlay = t;
119
119
  }, this.handleNoticeSlotChange = (e) => {
120
- this.hasNotice = o(e.target), this.handleMainSlotChange();
120
+ this.hasNotice = r(e.target), this.handleMainSlotChange();
121
121
  }, this.handleOverlayEscape = (e) => {
122
122
  let t = this.shadowRoot?.querySelector(".overlay");
123
123
  return !this.hasOverlay || !t || !e.composedPath().includes(t) ? !1 : (this.handleOverlayClose(), !0);
@@ -193,7 +193,7 @@ var M = class extends i {
193
193
  return this.config ? u`
194
194
  <!-- 반복 영역 건너뛰기(WCAG 2.4.1) — 셸의 첫 Tab. 포커스될 때만 보인다. -->
195
195
  <a class="skip-link" part="skip-link" href="#" @click=${this.handleSkipLinkClick}>
196
- ${a(this.locale || void 0).skipToContent}
196
+ ${i(this.locale || void 0).skipToContent}
197
197
  </a>
198
198
 
199
199
  <!-- Mobile Header -->
@@ -203,7 +203,7 @@ var M = class extends i {
203
203
  ${this.config.title}
204
204
  </span>
205
205
  <u-button class="toggler" appearance="plain"
206
- aria-label=${a(this.locale || void 0).toggleMobileMenu}
206
+ aria-label=${i(this.locale || void 0).toggleMobileMenu}
207
207
  @click=${this.handleToggleButtonClick}>
208
208
  <u-icon
209
209
  lib=${this.icon("lib")}
@@ -221,7 +221,7 @@ var M = class extends i {
221
221
  ${this.config.title}
222
222
  </span>
223
223
  <u-button class="toggler" appearance="plain"
224
- aria-label=${a(this.locale || void 0).toggleSidebar}
224
+ aria-label=${i(this.locale || void 0).toggleSidebar}
225
225
  @click=${this.handleToggleButtonClick}>
226
226
  <u-icon
227
227
  lib=${this.icon("lib")}
@@ -233,12 +233,12 @@ var M = class extends i {
233
233
  <!-- Sidebar Navigation Menu -->
234
234
  <nav class="sidebar-main" part="sidebar-main" scrollable
235
235
  aria-label=${this.config.mainAriaLabel ?? d}>
236
- ${_(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
236
+ ${_(o(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
237
237
  </nav>
238
238
 
239
239
  <!-- Sidebar Footer -->
240
240
  <div class="sidebar-footer" part="sidebar-footer">
241
- ${_(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
241
+ ${_(o(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
242
242
  </div>
243
243
  </aside>
244
244
 
@@ -247,7 +247,7 @@ var M = class extends i {
247
247
  <main class="main" part="main" scrollable tabindex=${this.mainTabStop ? "0" : "-1"} @keydown=${this._handleMainKeydown}>
248
248
  <u-progress-bar part="progress" class=${this.routeLoading ? "loading" : ""}
249
249
  aria-hidden=${this.routeLoading ? d : "true"}
250
- aria-label=${a(this.locale || void 0).pageLoading}></u-progress-bar>
250
+ aria-label=${i(this.locale || void 0).pageLoading}></u-progress-bar>
251
251
 
252
252
  <div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
253
253
  <div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
@@ -261,7 +261,7 @@ var M = class extends i {
261
261
 
262
262
  <div class="overlay ${this.hasOverlay ? "" : "empty"}" part="overlay">
263
263
  <u-button class="overlay-close" part="overlay-close" appearance="plain"
264
- aria-label=${a(this.locale || void 0).detailClose}
264
+ aria-label=${i(this.locale || void 0).detailClose}
265
265
  @click=${this.handleOverlayClose}>
266
266
  <u-icon lib=${this.icon("lib")} name=${this.icon("overlayClose")}></u-icon>
267
267
  </u-button>
@@ -470,9 +470,9 @@ var M = class extends i {
470
470
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
471
471
  }
472
472
  };
473
- r([p({
473
+ t([p({
474
474
  type: String,
475
475
  reflect: !0
476
- }), n("design:type", Object)], M.prototype, "state", void 0), r([p({ type: Object }), n("design:type", Object)], M.prototype, "config", void 0), r([p({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], M.prototype, "counts", void 0), r([p({ type: String }), n("design:type", Object)], M.prototype, "locale", void 0), r([m("u-progress-bar"), n("design:type", Object)], M.prototype, "progressBarEl", void 0), r([h(), n("design:type", Object)], M.prototype, "hasOverlay", void 0), r([h(), n("design:type", Object)], M.prototype, "openMenu", void 0), r([h(), n("design:type", Object)], M.prototype, "hasNotice", void 0), r([h(), n("design:type", Object)], M.prototype, "routeLoading", void 0), r([h(), n("design:type", Object)], M.prototype, "mainTabStop", void 0), r([h(), n("design:type", Object)], M.prototype, "currentPath", void 0), M = S = r([f("u-sidebar-layout")], M);
476
+ }), e("design:type", Object)], M.prototype, "state", void 0), t([p({ type: Object }), e("design:type", Object)], M.prototype, "config", void 0), t([p({ type: Object }), e("design:type", typeof Record > "u" ? Object : Record)], M.prototype, "counts", void 0), t([p({ type: String }), e("design:type", Object)], M.prototype, "locale", void 0), t([m("u-progress-bar"), e("design:type", Object)], M.prototype, "progressBarEl", void 0), t([h(), e("design:type", Object)], M.prototype, "hasOverlay", void 0), t([h(), e("design:type", Object)], M.prototype, "openMenu", void 0), t([h(), e("design:type", Object)], M.prototype, "hasNotice", void 0), t([h(), e("design:type", Object)], M.prototype, "routeLoading", void 0), t([h(), e("design:type", Object)], M.prototype, "mainTabStop", void 0), t([h(), e("design:type", Object)], M.prototype, "currentPath", void 0), M = S = t([f("u-sidebar-layout")], M);
477
477
  //#endregion
478
478
  export { M as SidebarLayout };
@@ -51,6 +51,8 @@ export interface AppConfig {
51
51
  routes?: RouteConfig[];
52
52
  /**
53
53
  * 라우팅 실패 시 대체 컨텐츠 설정
54
+ * - 주지 않으면 `<u-empty-state>` 로 그린다 — 403(가드 거부) `no-access` · 404 `not-found` ·
55
+ * 그 밖 `error`(오류 메시지가 설명). 주면 이것이 그대로 쓰인다.
54
56
  */
55
57
  fallback?: FallbackRouteConfig;
56
58
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/modern-app",
3
3
  "description": "web-framework by iyulab based on lit-element",
4
- "version": "0.46.0",
4
+ "version": "0.47.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -196,6 +196,9 @@ code 403 · `NotFoundError` · `ContentLoadError` · `ContentRenderError` · `Ou
196
196
  exported from `@iyulab/modern-app` — tell a blocked screen from a missing one with
197
197
  `ctx.error instanceof AccessDeniedError` and render it as `<u-empty-state variant="no-access">`.
198
198
 
199
+ Without a `fallback`, `app.load()` draws `<u-empty-state>` itself: 403 → `no-access` · 404 → `not-found` ·
200
+ anything else → `error` (title "Couldn’t open this page", the error message as description).
201
+
199
202
  ---
200
203
 
201
204
  ## `I18nInitOptions`
@@ -25,6 +25,9 @@ action is **asking for access**.
25
25
 
26
26
  <!-- A route guard said no (403) — not an outage, so it is not announced as an alert. -->
27
27
  <u-empty-state variant="no-access"></u-empty-state>
28
+
29
+ <!-- The address points at nothing (404) — the next step is checking the address or going back. -->
30
+ <u-empty-state variant="not-found"></u-empty-state>
28
31
  ```
29
32
 
30
33
  ## Slots
@@ -38,7 +41,7 @@ action is **asking for access**.
38
41
 
39
42
  | Property | Type | Default | Reflect | Description |
40
43
  |----------|------|---------|---------|-------------|
41
- | `variant` | `'no-data'\|'no-results'\|'error'\|'no-access'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title; `no-access` (this user may not see it) does not |
44
+ | `variant` | `'no-data'\|'no-results'\|'error'\|'no-access'\|'not-found'` | `'no-data'` | ✓ | Which fact is being shown; changes the default wording. `error` (could not load) announces its title; `no-access` (this user may not see it) and `not-found` (nothing at this address) do not |
42
45
  | `title` | `string` | `''` | | Override the default title |
43
46
  | `description` | `string` | `''` | | Override the default description |
44
47
  | `locale` | `string` | `''` | | Locale tag override for built-in strings |
@@ -69,18 +72,40 @@ action is **asking for access**.
69
72
  `@iyulab/components`, register others with `modernAppLocale.register(lang, …)`, or pass
70
73
  `title`/`description` per screen.
71
74
 
72
- ## Blocked routes
75
+ ## Blocked and missing routes
73
76
 
74
- A route `enter` guard that returns `false` raises `AccessDeniedError` (code 403) into the app
75
- `fallback`. The error classes are exported from `@iyulab/modern-app`, so the fallback can tell a
76
- blocked screen from a missing one without depending on the router directly:
77
+ **An app that gives `app.load()` no `fallback` gets this element by default**: a route `enter` guard
78
+ that returns `false` (`AccessDeniedError`, code 403) shows `no-access`, an address with no route
79
+ (`NotFoundError`, 404) shows `not-found`, and any other failure (the route could not be loaded or
80
+ rendered) shows `error` with the page-level title "Couldn’t open this page" and the error message as
81
+ its description. The screen is drawn inside the shell, so the sidebar stays.
82
+
83
+ ```typescript
84
+ import { app } from '@iyulab/modern-app';
85
+ import { html } from 'lit';
86
+
87
+ const canAudit = () => false; // your permission check
88
+
89
+ await app.load({
90
+ layout: { type: 'sidebar' },
91
+ routes: [{ path: '/audit', enter: canAudit, render: () => html`<audit-screen></audit-screen>` }],
92
+ // no fallback — /audit shows <u-empty-state variant="no-access">
93
+ });
94
+ ```
95
+
96
+ Give a `fallback` to draw something else — it replaces the default entirely. The error classes are
97
+ exported from `@iyulab/modern-app`, so it can tell a blocked screen from a missing one without
98
+ depending on the router directly:
77
99
 
78
100
  ```typescript
79
101
  import { app, AccessDeniedError } from '@iyulab/modern-app';
80
102
  import { html } from 'lit';
81
103
 
104
+ const canAudit = () => false; // your permission check
105
+
82
106
  await app.load({
83
- // …
107
+ layout: { type: 'sidebar' },
108
+ routes: [{ path: '/audit', enter: canAudit, render: () => html`<audit-screen></audit-screen>` }],
84
109
  fallback: {
85
110
  render: (ctx) => ctx.error instanceof AccessDeniedError
86
111
  ? html`<u-empty-state variant="no-access"></u-empty-state>`