@iyulab/modern-app 0.52.1 → 0.53.1
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 +46 -0
- package/README.md +6 -0
- package/dist/App.js +57 -52
- package/dist/components/SidebarButton.styles.js +5 -4
- package/dist/components/SidebarGroup.styles.js +4 -3
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -15
- package/dist/internals/locale.d.ts +7 -0
- package/dist/internals/locale.js +12 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.js +16 -11
- package/dist/types/AppConfigs.d.ts +11 -1
- package/dist/unsaved.d.ts +49 -0
- package/dist/unsaved.js +43 -0
- package/package.json +3 -3
- package/skills/modern-app/SKILL.md +29 -0
- package/skills/modern-app/references/api.md +12 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.53.1] - 2026-10-13
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **Group headers and sidebar buttons follow the navigation item tokens.** `--app-sidebar-item-gap`,
|
|
8
|
+
`--app-sidebar-item-padding` and `--app-sidebar-item-radius` were read by links only, and `--app-sidebar-icon-size` by
|
|
9
|
+
links and group headers — a theme that set them got three different label starts in one sidebar (links, group
|
|
10
|
+
headers, and footer buttons such as a theme toggle). All three item kinds now read all four. Without the tokens the
|
|
11
|
+
defaults are unchanged.
|
|
12
|
+
|
|
13
|
+
### Documentation
|
|
14
|
+
|
|
15
|
+
- The unsaved-changes examples are complete snippets for Lit and React, and hold `UnsavedChangesController` in a
|
|
16
|
+
`readonly` field (a `private` one is flagged by `noUnusedLocals`).
|
|
17
|
+
|
|
18
|
+
## [0.53.0] - 2026-10-13
|
|
19
|
+
|
|
20
|
+
### Added
|
|
21
|
+
|
|
22
|
+
- **Unsaved changes are not dropped without asking.** A screen registers one check —
|
|
23
|
+
`UnsavedChangesController(host, () => dirty)` (Lit), `useUnsavedChanges(dirty)` (`/react`) or
|
|
24
|
+
`trackUnsavedChanges(() => dirty)` — and both ways of leaving ask it. Before a navigation (a link,
|
|
25
|
+
`app.navigate()`, the sidebar, a selection channel, Back and Forward) the shell asks «Discard unsaved changes?» in
|
|
26
|
+
an in-app dialog — **Discard** leaves, **Keep editing** stays with the screen and the address unchanged. Leaving
|
|
27
|
+
the document (reload, another site, closing the tab) gets the browser's own `beforeunload` confirmation. When
|
|
28
|
+
nothing is unsaved, nothing is asked and nothing waits. A check that throws counts as unsaved. Keep the draft on
|
|
29
|
+
`input` rather than `change` — `change` fires only when a field is left.
|
|
30
|
+
- `confirmDiscardChanges(options?)` asks the same question anywhere — a modal drawer's cancellable `hide` event is
|
|
31
|
+
the place for «close without saving». `hasUnsavedChanges()` reads every registered check. `unsavedChangesGuard`
|
|
32
|
+
wires the same behaviour into a `Router` an app builds itself (`new Router({ leave: unsavedChangesGuard })`).
|
|
33
|
+
- `AppConfig.leave` — a global check of your own, asked before the unsaved-changes one (`@iyulab/router`'s
|
|
34
|
+
`RouterConfig.leave`). `RouteLeaveContext` is re-exported.
|
|
35
|
+
- Built-in strings `discardTitle`, `discardMessage`, `discardConfirm`, `discardCancel` (English and Korean).
|
|
36
|
+
|
|
37
|
+
### Changed
|
|
38
|
+
|
|
39
|
+
- **Requires `@iyulab/router` 0.24** (peer `^0.24.0`) — the version with `leave` guards.
|
|
40
|
+
|
|
41
|
+
### Documentation
|
|
42
|
+
|
|
43
|
+
- The installation section says how to check the 0.51 peer move: `npm ls @iyulab/router @iyulab/modern-app` should
|
|
44
|
+
list one version of each. An upgrade does not always fail when an older copy stays — in a workspace where another
|
|
45
|
+
package declares them as broad peers (`"*"`), the older copy keeps satisfying that package and stays installed with
|
|
46
|
+
no install error. Remove the older entries from the lockfile and `node_modules`, then install again.
|
|
47
|
+
`@iyulab/router` 0.23.1 names this cause when a second copy loads.
|
|
48
|
+
|
|
3
49
|
## [0.52.1] - 2026-10-12
|
|
4
50
|
|
|
5
51
|
### 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 "
|
|
4
|
-
import
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
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
|
|
11
|
-
function
|
|
12
|
-
if (!
|
|
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(
|
|
16
|
+
e.replaceSync(l), document.adoptedStyleSheets = [...document.adoptedStyleSheets, e], u = e;
|
|
16
17
|
} else {
|
|
17
18
|
let e = document.createElement("style");
|
|
18
|
-
e.textContent =
|
|
19
|
+
e.textContent = l, document.head.prepend(e), u = e;
|
|
19
20
|
}
|
|
20
21
|
}
|
|
21
22
|
}
|
|
22
|
-
var
|
|
23
|
+
var f = class l {
|
|
23
24
|
constructor() {}
|
|
24
25
|
static get instance() {
|
|
25
|
-
return
|
|
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
|
|
41
|
+
return o;
|
|
41
42
|
}
|
|
42
43
|
get i18n() {
|
|
43
|
-
return
|
|
44
|
+
return r;
|
|
44
45
|
}
|
|
45
|
-
async load(
|
|
46
|
-
if (this.unload(), this._config =
|
|
47
|
-
for (let e of
|
|
48
|
-
await
|
|
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 (
|
|
51
|
-
let e =
|
|
52
|
-
this.load(
|
|
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
|
|
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 (!
|
|
64
|
-
let r =
|
|
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 =
|
|
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
|
|
82
|
+
this._user = n.user, await s.auth.onAuthenticated?.(n.user);
|
|
82
83
|
}
|
|
83
|
-
let
|
|
84
|
-
this._layout = await this.createLayout(
|
|
85
|
-
element:
|
|
86
|
-
breakpoints:
|
|
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
|
|
89
|
-
this._layout.appendChild(
|
|
89
|
+
let l = document.createElement("u-outlet");
|
|
90
|
+
this._layout.appendChild(l), this._router = new i({
|
|
90
91
|
root: this._layout,
|
|
91
|
-
basepath:
|
|
92
|
-
mode:
|
|
93
|
-
routes:
|
|
94
|
-
fallback:
|
|
95
|
-
enter:
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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" && (
|
|
108
|
+
e && e !== "cimode" && (c.set(e), typeof document < "u" && (document.documentElement.lang = e));
|
|
104
109
|
};
|
|
105
|
-
e(
|
|
106
|
-
let t = (t) => e(
|
|
107
|
-
|
|
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
|
|
127
|
+
await s.notice(e, { ...t });
|
|
123
128
|
}
|
|
124
129
|
async info(e, t) {
|
|
125
|
-
await
|
|
130
|
+
await s.info(e, { ...t });
|
|
126
131
|
}
|
|
127
132
|
async warning(e, t) {
|
|
128
|
-
await
|
|
133
|
+
await s.warning(e, { ...t });
|
|
129
134
|
}
|
|
130
135
|
async success(e, t) {
|
|
131
|
-
await
|
|
136
|
+
await s.success(e, { ...t });
|
|
132
137
|
}
|
|
133
138
|
async error(e, t) {
|
|
134
|
-
await
|
|
139
|
+
await s.error(e, { ...t });
|
|
135
140
|
}
|
|
136
141
|
async createLayout(e, t) {
|
|
137
|
-
e === document.body &&
|
|
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 {
|
|
152
|
+
export { f as app };
|
|
@@ -7,7 +7,7 @@ var t = e`
|
|
|
7
7
|
color: var(--app-sidebar-fg, var(--u-txt-color, #212121));
|
|
8
8
|
background-color: transparent;
|
|
9
9
|
border: none;
|
|
10
|
-
border-radius: 8px;
|
|
10
|
+
border-radius: var(--app-sidebar-item-radius, 8px);
|
|
11
11
|
transition: all var(--u-duration-normal, 220ms) ease;
|
|
12
12
|
cursor: pointer;
|
|
13
13
|
}
|
|
@@ -34,8 +34,9 @@ var t = e`
|
|
|
34
34
|
flex-direction: row;
|
|
35
35
|
align-items: center;
|
|
36
36
|
justify-content: flex-start;
|
|
37
|
-
|
|
38
|
-
|
|
37
|
+
|
|
38
|
+
gap: var(--app-sidebar-item-gap, 12px);
|
|
39
|
+
padding: var(--app-sidebar-item-padding, 8px 12px);
|
|
39
40
|
|
|
40
41
|
min-height: var(--u-target-size, 0px);
|
|
41
42
|
}
|
|
@@ -52,7 +53,7 @@ var t = e`
|
|
|
52
53
|
u-icon {
|
|
53
54
|
flex-shrink: 0;
|
|
54
55
|
color: inherit;
|
|
55
|
-
font-size: 20px;
|
|
56
|
+
font-size: var(--app-sidebar-icon-size, 20px);
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
|
|
@@ -17,13 +17,14 @@ var t = e`
|
|
|
17
17
|
flex-direction: row;
|
|
18
18
|
align-items: center;
|
|
19
19
|
justify-content: space-between;
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
|
|
21
|
+
gap: var(--app-sidebar-item-gap, 12px);
|
|
22
|
+
padding: var(--app-sidebar-item-padding, 8px 12px);
|
|
22
23
|
|
|
23
24
|
min-height: var(--u-target-size, 0px);
|
|
24
25
|
background-color: transparent;
|
|
25
26
|
border: none;
|
|
26
|
-
border-radius: 8px;
|
|
27
|
+
border-radius: var(--app-sidebar-item-radius, 8px);
|
|
27
28
|
transition: all var(--u-duration-normal, 220ms) ease;
|
|
28
29
|
cursor: pointer;
|
|
29
30
|
}
|
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 {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
16
|
-
import {
|
|
17
|
-
import {
|
|
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
|
|
20
|
+
var A = d;
|
|
20
21
|
//#endregion
|
|
21
|
-
export {
|
|
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;
|
package/dist/internals/locale.js
CHANGED
|
@@ -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 {
|
|
3
|
-
import {
|
|
4
|
-
import r from "
|
|
5
|
-
import
|
|
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
|
-
|
|
8
|
-
|
|
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:
|
|
15
|
+
elementClass: r,
|
|
11
16
|
events: { onOverlayClose: "overlay-close" }
|
|
12
|
-
}),
|
|
13
|
-
react:
|
|
17
|
+
}), c = a({
|
|
18
|
+
react: i,
|
|
14
19
|
tagName: "u-wizard",
|
|
15
|
-
elementClass:
|
|
20
|
+
elementClass: n,
|
|
16
21
|
events: { onStepChange: "step-change" }
|
|
17
22
|
});
|
|
18
23
|
//#endregion
|
|
19
|
-
export { e as ScreenObserver,
|
|
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
|
+
* readonly 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
|
+
}
|
package/dist/unsaved.js
ADDED
|
@@ -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.
|
|
4
|
+
"version": "0.53.1",
|
|
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.
|
|
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.
|
|
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
|
+
readonly 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
|
|