@iyulab/modern-app 0.52.1 → 0.53.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,36 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.53.0] - 2026-10-13
4
+
5
+ ### Added
6
+
7
+ - **Unsaved changes are not dropped without asking.** A screen registers one check —
8
+ `UnsavedChangesController(host, () => dirty)` (Lit), `useUnsavedChanges(dirty)` (`/react`) or
9
+ `trackUnsavedChanges(() => dirty)` — and both ways of leaving ask it. Before a navigation (a link,
10
+ `app.navigate()`, the sidebar, a selection channel, Back and Forward) the shell asks «Discard unsaved changes?» in
11
+ an in-app dialog — **Discard** leaves, **Keep editing** stays with the screen and the address unchanged. Leaving
12
+ the document (reload, another site, closing the tab) gets the browser's own `beforeunload` confirmation. When
13
+ nothing is unsaved, nothing is asked and nothing waits. A check that throws counts as unsaved. Keep the draft on
14
+ `input` rather than `change` — `change` fires only when a field is left.
15
+ - `confirmDiscardChanges(options?)` asks the same question anywhere — a modal drawer's cancellable `hide` event is
16
+ the place for «close without saving». `hasUnsavedChanges()` reads every registered check. `unsavedChangesGuard`
17
+ wires the same behaviour into a `Router` an app builds itself (`new Router({ leave: unsavedChangesGuard })`).
18
+ - `AppConfig.leave` — a global check of your own, asked before the unsaved-changes one (`@iyulab/router`'s
19
+ `RouterConfig.leave`). `RouteLeaveContext` is re-exported.
20
+ - Built-in strings `discardTitle`, `discardMessage`, `discardConfirm`, `discardCancel` (English and Korean).
21
+
22
+ ### Changed
23
+
24
+ - **Requires `@iyulab/router` 0.24** (peer `^0.24.0`) — the version with `leave` guards.
25
+
26
+ ### Documentation
27
+
28
+ - The installation section says how to check the 0.51 peer move: `npm ls @iyulab/router @iyulab/modern-app` should
29
+ list one version of each. An upgrade does not always fail when an older copy stays — in a workspace where another
30
+ package declares them as broad peers (`"*"`), the older copy keeps satisfying that package and stays installed with
31
+ no install error. Remove the older entries from the lockfile and `node_modules`, then install again.
32
+ `@iyulab/router` 0.23.1 names this cause when a second copy loads.
33
+
3
34
  ## [0.52.1] - 2026-10-12
4
35
 
5
36
  ### Fixed
package/README.md CHANGED
@@ -8,6 +8,12 @@ A client-side SPA framework built on [Lit Element](https://lit.dev/) by iyulab.
8
8
  npm install @iyulab/modern-app
9
9
  ```
10
10
 
11
+ `@iyulab/router` and `@iyulab/components` are peer dependencies — npm 7+ and pnpm install them for you, and the
12
+ app shares the shell's copy. After installing or upgrading, `npm ls @iyulab/router @iyulab/modern-app` should list
13
+ one version of each. In a workspace where another package declares them as broad peers (`"*"`), an upgrade can
14
+ leave the older copy installed with no install error; remove the older entries from the lockfile and
15
+ `node_modules`, then install again.
16
+
11
17
  ## When to use it
12
18
 
13
19
  | ✅ Good fit | ❌ Not a good fit |
package/dist/App.js CHANGED
@@ -1,28 +1,29 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
2
  import { defaultFallback as t } from "./defaultFallback.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";
3
+ import { unsavedChangesGuard as n } from "./unsaved.js";
4
+ import r from "i18next";
5
+ import { Router as i } from "@iyulab/router";
6
+ import { setDefaultBaseUrl as a } from "@iyulab/components/dist/utilities/icons.js";
7
+ import { Theme as o } from "@iyulab/components/dist/utilities/Theme.js";
8
+ import { Toast as s } from "@iyulab/components/dist/utilities/Toast.js";
9
+ import { Locale as c } from "@iyulab/components/dist/utilities/Locale.js";
9
10
  //#region src/App.ts
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) {
11
+ var l = "\n:where(body) { margin: 0; }\n@media screen {\n :where(body) { width: 100vw; height: 100vh; }\n}\n", u;
12
+ function d() {
13
+ if (!u) {
13
14
  if ("adoptedStyleSheets" in document && typeof CSSStyleSheet < "u" && typeof CSSStyleSheet.prototype.replaceSync == "function") {
14
15
  let e = new CSSStyleSheet();
15
- e.replaceSync(c), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], l = e;
16
+ e.replaceSync(l), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], u = e;
16
17
  } else {
17
18
  let e = document.createElement("style");
18
- e.textContent = c, document.head.prepend(e), l = e;
19
+ e.textContent = l, document.head.prepend(e), u = e;
19
20
  }
20
21
  }
21
22
  }
22
- var d = class c {
23
+ var f = class l {
23
24
  constructor() {}
24
25
  static get instance() {
25
- return c._instance ||= new c(), c._instance;
26
+ return l._instance ||= new l(), l._instance;
26
27
  }
27
28
  get config() {
28
29
  return this._config;
@@ -37,22 +38,22 @@ var d = class c {
37
38
  return this._user;
38
39
  }
39
40
  get theme() {
40
- return a;
41
+ return o;
41
42
  }
42
43
  get i18n() {
43
- return n;
44
+ return r;
44
45
  }
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();
46
+ async load(s) {
47
+ if (this.unload(), this._config = s, await o.init(s.theme), s.iconBasepath && a(s.iconBasepath), s.i18n) {
48
+ for (let e of s.i18n.plugins || []) r.use(e);
49
+ await r.init(s.i18n), this.syncLanguage();
49
50
  }
50
- if (o.auth) {
51
- let e = o.root || document.body, t = () => {
52
- this.load(o);
51
+ if (s.auth) {
52
+ let e = s.root || document.body, t = () => {
53
+ this.load(s);
53
54
  }, n;
54
55
  try {
55
- n = await o.auth.me();
56
+ n = await s.auth.me();
56
57
  } catch (e) {
57
58
  n = {
58
59
  status: "unknown",
@@ -60,8 +61,8 @@ var d = class c {
60
61
  };
61
62
  }
62
63
  if (n?.status === "unknown") {
63
- if (!o.auth.renderUnavailable) throw n.error;
64
- let r = o.auth.renderUnavailable({
64
+ if (!s.auth.renderUnavailable) throw n.error;
65
+ let r = s.auth.renderUnavailable({
65
66
  root: e,
66
67
  error: n.error,
67
68
  retry: t
@@ -70,7 +71,7 @@ var d = class c {
70
71
  return;
71
72
  }
72
73
  if (n?.status === "anonymous") {
73
- let n = o.auth.renderLogin({
74
+ let n = s.auth.renderLogin({
74
75
  root: e,
75
76
  onSuccess: t
76
77
  });
@@ -78,33 +79,37 @@ var d = class c {
78
79
  return;
79
80
  }
80
81
  if (n?.status !== "authenticated") throw TypeError("auth.me() must return { status: 'authenticated', user } | { status: 'anonymous' } | { status: 'unknown', error }");
81
- this._user = n.user, await o.auth.onAuthenticated?.(n.user);
82
+ this._user = n.user, await s.auth.onAuthenticated?.(n.user);
82
83
  }
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]
84
+ let c = s.root || document.body;
85
+ this._layout = await this.createLayout(c, s.layout), this._screen = new e({
86
+ element: c,
87
+ breakpoints: s.layout.breakpoints || [768, 1024]
87
88
  });
88
- let c = document.createElement("u-outlet");
89
- this._layout.appendChild(c), this._router = new r({
89
+ let l = document.createElement("u-outlet");
90
+ this._layout.appendChild(l), this._router = new i({
90
91
  root: this._layout,
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,
98
- scrollRestoration: o.scrollRestoration !== !1 && !o.layout.scrollBehavior && { container: () => this._layout?.mainElement }
92
+ basepath: s.basepath,
93
+ mode: s.routerMode,
94
+ routes: s.routes,
95
+ fallback: s.fallback ?? t,
96
+ enter: s.enter,
97
+ leave: (e) => {
98
+ let t = !s.leave || s.leave(e);
99
+ return t === !1 ? !1 : t === !0 ? n(e) : Promise.resolve(t).then((t) => t !== !1 && n(e));
100
+ },
101
+ initialLoad: s.initialLoad,
102
+ useIntercept: s.useIntercept,
103
+ scrollRestoration: s.scrollRestoration !== !1 && !s.layout.scrollBehavior && { container: () => this._layout?.mainElement }
99
104
  });
100
105
  }
101
106
  syncLanguage() {
102
107
  let e = (e) => {
103
- e && e !== "cimode" && (s.set(e), typeof document < "u" && (document.documentElement.lang = e));
108
+ e && e !== "cimode" && (c.set(e), typeof document < "u" && (document.documentElement.lang = e));
104
109
  };
105
- e(n.resolvedLanguage ?? n.language);
106
- let t = (t) => e(n.resolvedLanguage ?? t);
107
- n.on("languageChanged", t), this._languageSync = () => n.off("languageChanged", t);
110
+ e(r.resolvedLanguage ?? r.language);
111
+ let t = (t) => e(r.resolvedLanguage ?? t);
112
+ r.on("languageChanged", t), this._languageSync = () => r.off("languageChanged", t);
108
113
  }
109
114
  unload() {
110
115
  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;
@@ -119,22 +124,22 @@ var d = class c {
119
124
  t === void 0 ? delete r[e] : r[e] = t, n.counts = r;
120
125
  }
121
126
  async notice(e, t) {
122
- await o.notice(e, { ...t });
127
+ await s.notice(e, { ...t });
123
128
  }
124
129
  async info(e, t) {
125
- await o.info(e, { ...t });
130
+ await s.info(e, { ...t });
126
131
  }
127
132
  async warning(e, t) {
128
- await o.warning(e, { ...t });
133
+ await s.warning(e, { ...t });
129
134
  }
130
135
  async success(e, t) {
131
- await o.success(e, { ...t });
136
+ await s.success(e, { ...t });
132
137
  }
133
138
  async error(e, t) {
134
- await o.error(e, { ...t });
139
+ await s.error(e, { ...t });
135
140
  }
136
141
  async createLayout(e, t) {
137
- e === document.body && u();
142
+ e === document.body && d();
138
143
  let n;
139
144
  if (t.type === "sidebar") {
140
145
  let { SidebarLayout: e } = await import("./layouts/SidebarLayout.js"), r = new e();
@@ -144,4 +149,4 @@ var d = class c {
144
149
  }
145
150
  }.instance;
146
151
  //#endregion
147
- export { d as app };
152
+ export { f as app };
package/dist/index.d.ts CHANGED
@@ -3,7 +3,7 @@ export type * from './types/AppConfigs';
3
3
  export type * from './types/AppOptions';
4
4
  export type * from './types/AuthConfig';
5
5
  export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
6
- export type { RouteConfig, RouteContext, FallbackRouteConfig } from '@iyulab/router';
6
+ export type { RouteConfig, RouteContext, FallbackRouteConfig, RouteLeaveContext } from '@iyulab/router';
7
7
  export { RouteError, NotFoundError, AccessDeniedError, ContentLoadError, ContentRenderError, OutletMissingError, } from '@iyulab/router';
8
8
  export { defaultFallback } from './defaultFallback.js';
9
9
  export type { SidebarIconsConfig, SidebarLayoutConfig, SidebarItem, SidebarLogoConfig, SidebarLinkConfig, SidebarSectionConfig, SidebarGroupConfig, SidebarButtonConfig, SidebarMenuConfig, SidebarMenuItemConfig, SidebarHtmlConfig, SidebarState, } from './layouts/SidebarLayout.types';
@@ -22,6 +22,8 @@ export { MasterDetailLayout } from './components/MasterDetailLayout.js';
22
22
  export type { MasterDetailLayoutEventMap } from './components/MasterDetailLayout.js';
23
23
  export { Wizard } from './components/Wizard.js';
24
24
  export type { WizardStep, WizardStepState, WizardStepChangeDetail, WizardEventMap } from './components/Wizard.js';
25
+ export { trackUnsavedChanges, hasUnsavedChanges, confirmDiscardChanges, unsavedChangesGuard, UnsavedChangesController, } from './unsaved.js';
26
+ export type { DiscardChangesOptions } from './unsaved.js';
25
27
  export { translate } from './translate.js';
26
28
  export { modernAppLocale, getLocaleStrings, getDefaultLocale, } from './internals/locale.js';
27
29
  export type { ModernAppLocaleStrings, ModernAppMessageKey } from './internals/locale.js';
package/dist/index.js CHANGED
@@ -2,20 +2,21 @@ import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
2
  import { getDefaultLocale as t, getLocaleStrings as n, modernAppLocale as r } from "./internals/locale.js";
3
3
  import { EmptyState as i } from "./components/EmptyState.js";
4
4
  import { defaultFallback as a } from "./defaultFallback.js";
5
- import { app as o } from "./App.js";
6
- import { filterSidebarItems as s } from "./layouts/filterSidebarItems.js";
7
- import { PageHeader as c } from "./components/PageHeader.js";
8
- import { GroupBox as l } from "./components/GroupBox.js";
9
- import { InfoSection as u } from "./components/InfoSection.js";
10
- import { InfoField as d, isBlank as f } from "./components/InfoField.js";
11
- import { TimelineItem as p } from "./components/TimelineItem.js";
12
- import { Timeline as m } from "./components/Timeline.js";
13
- import { ActionBar as h } from "./components/ActionBar.js";
14
- import { MasterDetailLayout as g } from "./components/MasterDetailLayout.js";
15
- import { Wizard as _ } from "./components/Wizard.js";
16
- import { translate as v } from "./translate.js";
17
- import { AccessDeniedError as y, ContentLoadError as b, ContentRenderError as x, NotFoundError as S, OutletMissingError as C, RouteError as w } from "@iyulab/router";
5
+ import { UnsavedChangesController as o, confirmDiscardChanges as s, hasUnsavedChanges as c, trackUnsavedChanges as l, unsavedChangesGuard as u } from "./unsaved.js";
6
+ import { app as d } from "./App.js";
7
+ import { filterSidebarItems as f } from "./layouts/filterSidebarItems.js";
8
+ import { PageHeader as p } from "./components/PageHeader.js";
9
+ import { GroupBox as m } from "./components/GroupBox.js";
10
+ import { InfoSection as h } from "./components/InfoSection.js";
11
+ import { InfoField as g, isBlank as _ } from "./components/InfoField.js";
12
+ import { TimelineItem as v } from "./components/TimelineItem.js";
13
+ import { Timeline as y } from "./components/Timeline.js";
14
+ import { ActionBar as b } from "./components/ActionBar.js";
15
+ import { MasterDetailLayout as x } from "./components/MasterDetailLayout.js";
16
+ import { Wizard as S } from "./components/Wizard.js";
17
+ import { translate as C } from "./translate.js";
18
+ import { AccessDeniedError as w, ContentLoadError as T, ContentRenderError as E, NotFoundError as D, OutletMissingError as O, RouteError as k } from "@iyulab/router";
18
19
  //#region src/index.ts
19
- var T = o;
20
+ var A = d;
20
21
  //#endregion
21
- export { y as AccessDeniedError, h as ActionBar, b as ContentLoadError, x as ContentRenderError, i as EmptyState, l as GroupBox, d as InfoField, u as InfoSection, g as MasterDetailLayout, S as NotFoundError, C as OutletMissingError, c as PageHeader, w as RouteError, e as ScreenObserver, m as Timeline, p as TimelineItem, _ as Wizard, o as app, T as default, a as defaultFallback, s as filterSidebarItems, t as getDefaultLocale, n as getLocaleStrings, f as isBlank, r as modernAppLocale, v as translate };
22
+ export { w as AccessDeniedError, b as ActionBar, T as ContentLoadError, E as ContentRenderError, i as EmptyState, m as GroupBox, g as InfoField, h as InfoSection, x as MasterDetailLayout, D as NotFoundError, O as OutletMissingError, p as PageHeader, k as RouteError, e as ScreenObserver, y as Timeline, v as TimelineItem, o as UnsavedChangesController, S as Wizard, d as app, s as confirmDiscardChanges, A as default, a as defaultFallback, f as filterSidebarItems, t as getDefaultLocale, n as getLocaleStrings, c as hasUnsavedChanges, _ as isBlank, r as modernAppLocale, l as trackUnsavedChanges, C as translate, u as unsavedChangesGuard };
@@ -32,6 +32,13 @@ export interface ModernAppLocaleStrings {
32
32
  /** Sidebar layout — accessible name of the route-loading bar at the top of the content (it is hidden from
33
33
  * assistive technology while idle). */
34
34
  pageLoading: string;
35
+ /** Unsaved changes — the confirmation asked before a screen with unsaved changes is left (`confirmDiscardChanges`). */
36
+ discardTitle: string;
37
+ discardMessage: string;
38
+ /** The button that leaves and drops the changes. */
39
+ discardConfirm: string;
40
+ /** The button that stays. */
41
+ discardCancel: string;
35
42
  /** Wizard — default Back/Next action labels ("Submit" wording is the consumer's call). */
36
43
  wizardBack: string;
37
44
  wizardNext: string;
@@ -19,6 +19,10 @@ var t = {
19
19
  skipToContent: "Skip to main content",
20
20
  opensInNewTab: "(opens in a new tab)",
21
21
  pageLoading: "Loading page",
22
+ discardTitle: "Discard unsaved changes?",
23
+ discardMessage: "Changes on this screen have not been saved.",
24
+ discardConfirm: "Discard",
25
+ discardCancel: "Keep editing",
22
26
  wizardBack: "Back",
23
27
  wizardNext: "Next",
24
28
  wizardStepAnnouncement: "Step {index} of {total}: {label}"
@@ -42,6 +46,10 @@ n.register("en", t), n.register("ko", {
42
46
  skipToContent: "본문으로 건너뛰기",
43
47
  opensInNewTab: ", 새 창에서 열림",
44
48
  pageLoading: "페이지 불러오는 중",
49
+ discardTitle: "저장하지 않은 변경을 버릴까요?",
50
+ discardMessage: "이 화면에서 바꾼 내용이 저장되지 않았습니다.",
51
+ discardConfirm: "버리기",
52
+ discardCancel: "계속 편집",
45
53
  wizardBack: "이전",
46
54
  wizardNext: "다음",
47
55
  wizardStepAnnouncement: "{total}단계 중 {index}단계: {label}"
@@ -71,6 +79,10 @@ function i(e) {
71
79
  skipToContent: t("skipToContent"),
72
80
  opensInNewTab: t("opensInNewTab"),
73
81
  pageLoading: t("pageLoading"),
82
+ discardTitle: t("discardTitle"),
83
+ discardMessage: t("discardMessage"),
84
+ discardConfirm: t("discardConfirm"),
85
+ discardCancel: t("discardCancel"),
74
86
  wizardBack: t("wizardBack"),
75
87
  wizardNext: t("wizardNext"),
76
88
  wizardStepAnnouncement: (t, n, i) => r(e, "wizardStepAnnouncement", {
package/dist/react.d.ts CHANGED
@@ -8,6 +8,18 @@ import { TimelineItemColor } from './components/TimelineItem.js';
8
8
  import { InfoFieldFormat, InfoFieldSize, InfoFieldTrend, InfoFieldTone } from './components/InfoField.js';
9
9
  export { ScreenObserver } from './internals/ScreenObserver.js';
10
10
  export type { ScreenSize, ScreenObserverConfig, ScreenResizeEvent } from './internals/ScreenObserver.js';
11
+ /**
12
+ * React 화면의 «저장 안 한 변경» — 마운트된 동안 판정을 등록한다(`trackUnsavedChanges`). 셸(`app.load()`)이 떠나기 전에
13
+ * «변경을 버릴까요?» 를 묻고, 문서 이탈(새로 고침 · 탭 닫기)에는 브라우저 확인이 걸린다. 값은 매 렌더의 것을 본다.
14
+ *
15
+ * ```tsx
16
+ * function OrderForm() {
17
+ * const [draft, setDraft] = useState(initial);
18
+ * useUnsavedChanges(draft !== initial);
19
+ * }
20
+ * ```
21
+ */
22
+ export declare function useUnsavedChanges(dirty: boolean): void;
11
23
  /**
12
24
  * React wrapper for `<u-sidebar-layout>`. `config` is an `@property({ type: Object })` —
13
25
  * a raw custom element handles that correctly under React 19 (property assignment) but not
package/dist/react.js CHANGED
@@ -1,19 +1,24 @@
1
1
  import { ScreenObserver as e } from "./internals/ScreenObserver.js";
2
- import { Wizard as t } from "./components/Wizard.js";
3
- import { SidebarLayout as n } from "./layouts/SidebarLayout.js";
4
- import r from "react";
5
- import { createComponent as i } from "@lit/react";
2
+ import { trackUnsavedChanges as t } from "./unsaved.js";
3
+ import { Wizard as n } from "./components/Wizard.js";
4
+ import { SidebarLayout as r } from "./layouts/SidebarLayout.js";
5
+ import i from "react";
6
+ import { createComponent as a } from "@lit/react";
6
7
  //#region src/react.ts
7
- var a = i({
8
- react: r,
8
+ function o(e) {
9
+ let n = i.useRef(e);
10
+ n.current = e, i.useEffect(() => t(() => n.current), []);
11
+ }
12
+ var s = a({
13
+ react: i,
9
14
  tagName: "u-sidebar-layout",
10
- elementClass: n,
15
+ elementClass: r,
11
16
  events: { onOverlayClose: "overlay-close" }
12
- }), o = i({
13
- react: r,
17
+ }), c = a({
18
+ react: i,
14
19
  tagName: "u-wizard",
15
- elementClass: t,
20
+ elementClass: n,
16
21
  events: { onStepChange: "step-change" }
17
22
  });
18
23
  //#endregion
19
- export { e as ScreenObserver, a as SidebarLayout, o as Wizard };
24
+ export { e as ScreenObserver, s as SidebarLayout, c as Wizard, o as useUnsavedChanges };
@@ -1,5 +1,5 @@
1
1
  import { InitOptions, Module, Newable, NewableModule } from 'i18next';
2
- import { RouteConfig, FallbackRouteConfig, RouteContext } from '@iyulab/router';
2
+ import { RouteConfig, FallbackRouteConfig, RouteContext, RouteLeaveContext } from '@iyulab/router';
3
3
  import { ThemeInitOptions } from '@iyulab/components/dist/utilities/Theme.js';
4
4
  import { SidebarLayoutConfig } from '../layouts/SidebarLayout.types';
5
5
  import { AuthGateConfig } from './AuthConfig';
@@ -72,6 +72,16 @@ export interface AppConfig {
72
72
  * ```
73
73
  */
74
74
  enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
75
+ /**
76
+ * 모든 라우트 전환 전에, 지금 화면을 떠나기 전에 호출되는 글로벌 leave 함수입니다(`@iyulab/router` 의 `RouterConfig.leave`).
77
+ * - `false` 반환: 이동 취소(화면과 주소가 그대로)
78
+ * - `true`/무반환: 통과
79
+ *
80
+ * 저장 안 한 변경은 이것 없이 막힌다 — 화면이 `trackUnsavedChanges(isDirty)`(Lit `UnsavedChangesController` · React
81
+ * `useUnsavedChanges`)로 판정을 등록하면 셸이 이 함수 다음에 «변경을 버릴까요?» 를 묻고, 문서 이탈(새로 고침 · 탭 닫기)에는
82
+ * 브라우저 확인을 건다. 이 함수는 그 밖의 «떠나도 되는가» 판정의 자리다.
83
+ */
84
+ leave?: (ctx: RouteLeaveContext) => Promise<boolean> | boolean;
75
85
  /**
76
86
  * 초기 로드 시 현재 URL로 라우팅을 자동으로 수행할지 여부를 설정합니다.
77
87
  * @default true
@@ -0,0 +1,49 @@
1
+ import { ReactiveController, ReactiveControllerHost } from 'lit';
2
+ import { RouteLeaveContext } from '@iyulab/router';
3
+ /**
4
+ * 화면의 «저장 안 한 변경» 판정을 등록한다 — 돌려받은 함수로 해제한다(화면이 사라질 때).
5
+ *
6
+ * 등록이 하나라도 있는 동안 문서 이탈에 `beforeunload` 확인이 걸린다(판정이 `true` 일 때만 묻는다).
7
+ * @example
8
+ * ```ts
9
+ * const release = trackUnsavedChanges(() => form.dirty);
10
+ * // 화면이 내려갈 때
11
+ * release();
12
+ * ```
13
+ */
14
+ export declare function trackUnsavedChanges(isDirty: () => boolean): () => void;
15
+ /** 등록된 판정 중 하나라도 «저장 안 한 변경이 있다» 인가. 던지는 판정은 «있다» 로 본다(잃는 쪽보다 묻는 쪽). */
16
+ export declare function hasUnsavedChanges(): boolean;
17
+ /** `confirmDiscardChanges` 의 문구 — 주지 않은 것은 `modernAppLocale` 의 `discard*` 문자열(영어·한국어 내장). */
18
+ export interface DiscardChangesOptions {
19
+ title?: string;
20
+ message?: string;
21
+ confirmLabel?: string;
22
+ cancelLabel?: string;
23
+ }
24
+ /**
25
+ * «변경을 버리고 떠날까요?» 를 묻는다 — 버리면 `true`. 판정 없이 늘 묻는다(묻기 전에 판정은 호출하는 쪽이 —
26
+ * `hasUnsavedChanges()` 나 자기 폼의 상태). 모달 서랍을 닫기 전처럼 라우터 밖의 «떠남» 에도 같은 문장을 쓴다.
27
+ */
28
+ export declare function confirmDiscardChanges(options?: DiscardChangesOptions): Promise<boolean>;
29
+ /**
30
+ * 라우터 `leave` 가드 — 저장 안 한 변경이 있으면 `confirmDiscardChanges()` 로 묻는다. `app.load()` 가 이미 배선한다.
31
+ * `Router` 를 직접 만드는 앱: `new Router({ …, leave: unsavedChangesGuard })`.
32
+ */
33
+ export declare function unsavedChangesGuard(_ctx?: RouteLeaveContext): boolean | Promise<boolean>;
34
+ /**
35
+ * Lit 요소의 «저장 안 한 변경» — 연결된 동안 판정을 등록하고, 떨어지면 해제한다.
36
+ * @example
37
+ * ```ts
38
+ * class OrderPage extends LitElement {
39
+ * private unsaved = new UnsavedChangesController(this, () => this.form.dirty);
40
+ * }
41
+ * ```
42
+ */
43
+ export declare class UnsavedChangesController implements ReactiveController {
44
+ private readonly isDirty;
45
+ private release?;
46
+ constructor(host: ReactiveControllerHost, isDirty: () => boolean);
47
+ hostConnected(): void;
48
+ hostDisconnected(): void;
49
+ }
@@ -0,0 +1,43 @@
1
+ import { getLocaleStrings as e } from "./internals/locale.js";
2
+ //#region src/unsaved.ts
3
+ var t = /* @__PURE__ */ new Set(), n = (e) => {
4
+ i() && (e.preventDefault(), e.returnValue = "");
5
+ };
6
+ function r(e) {
7
+ return t.size === 0 && typeof window < "u" && window.addEventListener("beforeunload", n), t.add(e), () => {
8
+ t.delete(e) && t.size === 0 && typeof window < "u" && window.removeEventListener("beforeunload", n);
9
+ };
10
+ }
11
+ function i() {
12
+ for (let e of t) try {
13
+ if (e()) return !0;
14
+ } catch (e) {
15
+ return console.error("Unsaved-changes check failed — treating the screen as unsaved:", e), !0;
16
+ }
17
+ return !1;
18
+ }
19
+ async function a(t) {
20
+ let n = e(), { Dialog: r } = await import("@iyulab/components/dist/utilities/Dialog.js");
21
+ return r.confirm(t?.message ?? n.discardMessage, {
22
+ title: t?.title ?? n.discardTitle,
23
+ confirmLabel: t?.confirmLabel ?? n.discardConfirm,
24
+ cancelLabel: t?.cancelLabel ?? n.discardCancel,
25
+ confirmColor: "danger"
26
+ });
27
+ }
28
+ function o(e) {
29
+ return !i() || a();
30
+ }
31
+ var s = class {
32
+ constructor(e, t) {
33
+ this.isDirty = t, e.addController(this);
34
+ }
35
+ hostConnected() {
36
+ this.release ??= r(this.isDirty);
37
+ }
38
+ hostDisconnected() {
39
+ this.release?.(), this.release = void 0;
40
+ }
41
+ };
42
+ //#endregion
43
+ export { s as UnsavedChangesController, a as confirmDiscardChanges, i as hasUnsavedChanges, r as trackUnsavedChanges, o as unsavedChangesGuard };
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.52.1",
4
+ "version": "0.53.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -65,7 +65,7 @@
65
65
  },
66
66
  "peerDependencies": {
67
67
  "@iyulab/components": ">=2.8.0",
68
- "@iyulab/router": "^0.23.0",
68
+ "@iyulab/router": "^0.24.0",
69
69
  "@lit/react": "^1.0.8",
70
70
  "react": "^18.0.0 || ^19.0.0"
71
71
  },
@@ -79,7 +79,7 @@
79
79
  },
80
80
  "devDependencies": {
81
81
  "@iyulab/components": "^2.20.3",
82
- "@iyulab/router": "^0.23.0",
82
+ "@iyulab/router": "^0.24.0",
83
83
  "@lit/react": "^1.0.8",
84
84
  "@types/node": "^26.1.1",
85
85
  "@types/react": "^19.3.0",
@@ -20,6 +20,12 @@ A client-side SPA framework built on Lit Element. Provides a single `app` single
20
20
  npm install @iyulab/modern-app
21
21
  ```
22
22
 
23
+ `@iyulab/router` and `@iyulab/components` are peer dependencies — npm 7+ and pnpm install them for you, and the
24
+ app shares the shell's copy. After installing or upgrading, `npm ls @iyulab/router @iyulab/modern-app` should list
25
+ one version of each. In a workspace where another package declares them as broad peers (`"*"`), an upgrade can
26
+ leave the older copy installed with no install error; remove the older entries from the lockfile and
27
+ `node_modules`, then install again.
28
+
23
29
  ---
24
30
 
25
31
  ## Bootstrap
@@ -230,6 +236,29 @@ Sidebar states: `'default'` | `'slim'` | `'modal'` | `'mobile'` | `'mobile-open'
230
236
 
231
237
  ---
232
238
 
239
+ ## Unsaved changes
240
+
241
+ Register a screen's «unsaved» check; the shell asks before any navigation (in-app dialog: Discard / Keep editing,
242
+ Back/Forward included) and the browser asks before the document is left (`beforeunload`). Clean screens ask nothing.
243
+
244
+ ```ts
245
+ import { UnsavedChangesController, confirmDiscardChanges } from '@iyulab/modern-app';
246
+ // Lit
247
+ private unsaved = new UnsavedChangesController(this, () => this.form.dirty);
248
+ // React: useUnsavedChanges(dirty) from '@iyulab/modern-app/react'
249
+ // Plain: const release = trackUnsavedChanges(() => dirty);
250
+ // A modal drawer's close is not a navigation — ask from its cancellable `hide` event:
251
+ drawer.addEventListener('hide', async (e) => {
252
+ if (!form.dirty) return;
253
+ e.preventDefault();
254
+ if (await confirmDiscardChanges()) { form.reset(); drawer.hide(); }
255
+ });
256
+ ```
257
+
258
+ `app.load({ leave })` runs first; `Router` built by hand: `new Router({ leave: unsavedChangesGuard })`.
259
+ Track the draft on `input`, not `change` — `change` fires only when the field is left, so text typed before Escape
260
+ or Back would read as clean.
261
+
233
262
  ## Routes with progress
234
263
 
235
264
  ```typescript
@@ -28,6 +28,12 @@ interface AppConfig {
28
28
  */
29
29
  enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
30
30
 
31
+ /**
32
+ * Global guard called before the current screen is replaced (after the route `leave` guards).
33
+ * `false` = stay. Unsaved changes do not need it — see «Unsaved changes» in routing.md.
34
+ */
35
+ leave?: (ctx: RouteLeaveContext) => Promise<boolean> | boolean;
36
+
31
37
  /** Auto-navigate to the current URL on load. Default: true */
32
38
  initialLoad?: boolean;
33
39
 
@@ -134,6 +140,12 @@ interface RouteConfig {
134
140
  */
135
141
  enter?: (context: RouteContext) => Promise<string | boolean> | string | boolean;
136
142
 
143
+ /**
144
+ * Guard called before this route's screen is replaced — `false` stays (Back/Forward included).
145
+ * Gets `{ from, to }`; `to` is the destination's address.
146
+ */
147
+ leave?: (context: RouteLeaveContext) => Promise<boolean> | boolean;
148
+
137
149
  /** Child routes, matched relative to this route's path. */
138
150
  children?: RouteConfig[];
139
151