@iyulab/modern-app 0.37.2 → 0.39.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 +41 -0
- package/README.md +7 -1
- package/dist/App.d.ts +1 -1
- package/dist/App.js +21 -9
- package/dist/components/InfoField.d.ts +6 -3
- package/dist/components/InfoField.styles.js +8 -0
- package/dist/components/SidebarGroup.styles.js +6 -3
- package/dist/layouts/SidebarLayout.d.ts +6 -3
- package/dist/layouts/SidebarLayout.js +70 -69
- package/dist/types/AppConfigs.d.ts +8 -2
- package/dist/types/AuthConfig.d.ts +19 -2
- package/package.json +2 -2
- package/skills/modern-app/references/api.md +22 -1
- package/skills/modern-app/references/components/info-field.md +4 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,46 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.39.0] - 2026-10-06
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **`u-sidebar-layout` marks the current menu on arrival.** It learned the current route only from `route-begin`, so
|
|
8
|
+
when the layout is rendered by a route (a React shell as a nested route's parent), the first navigation's event
|
|
9
|
+
had passed before the layout connected: after typing an address or reloading, no link was `selected` and none
|
|
10
|
+
had `aria-current`, until the user navigated once. It now starts from the current location (`currentRoutePath()`
|
|
11
|
+
from `@iyulab/router` 0.20).
|
|
12
|
+
- **Sidebar group icons follow `--app-sidebar-icon-size`**, like links. They used a fixed 20px, so a theme setting
|
|
13
|
+
the token (16px in `@iyulab/house-style`) drew two icon sizes in one rail.
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- **`--app-sidebar-group-active-fg`** — text of a group header whose group holds the active menu. It sits on the
|
|
18
|
+
panel, but its default is derived from the active *background* (85% + black), which works for a dark active
|
|
19
|
+
background and leaves a light one (`--hs-brand-soft`) with near-invisible text (about 1.6:1). A theme with a
|
|
20
|
+
light active background sets this to its ink color. Unset, nothing changes.
|
|
21
|
+
|
|
22
|
+
### Changed
|
|
23
|
+
|
|
24
|
+
- Requires `@iyulab/router` `^0.20.0`.
|
|
25
|
+
|
|
26
|
+
## [0.38.0] - 2026-10-06
|
|
27
|
+
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- **`u-info-field` `tone="warning"`** — "needs attention, not wrong" (a deadline coming up, a rate under target
|
|
31
|
+
but within limits). Paints the value and the trend with `--u-warning-color-strong`, like `positive`/`negative`
|
|
32
|
+
use the success/danger tokens. It is never inferred from `trend`, so existing fields are unchanged.
|
|
33
|
+
- **`auth.renderUnavailable({ root, error, retry })`** on the boot auth gate — drawn when `me()` throws, that is,
|
|
34
|
+
when the session could not be checked (server down, offline). The gate no longer has only "signed in" or
|
|
35
|
+
"show the login screen": `me()` should throw rather than return `null` when it got no answer, and `retry()`
|
|
36
|
+
checks again. Without `renderUnavailable`, `app.load()` rejects with the error as before.
|
|
37
|
+
|
|
38
|
+
### Documentation
|
|
39
|
+
|
|
40
|
+
- The auth gate examples follow the three-state session lookup of `@iyulab/enterprise` 0.25
|
|
41
|
+
(`fetchMe()` returns `{ status }`): the README example used to pass the lookup straight through, which with
|
|
42
|
+
0.25 would treat every answer as signed in.
|
|
43
|
+
|
|
3
44
|
## [0.37.2] - 2026-10-06
|
|
4
45
|
|
|
5
46
|
### Fixed
|
package/README.md
CHANGED
|
@@ -182,8 +182,13 @@ const auth = createAuthClient<User, Cred>({ meUrl: '/api/auth/me', loginUrl: '/a
|
|
|
182
182
|
await app.load({
|
|
183
183
|
layout: { type: 'sidebar', /* ... */ },
|
|
184
184
|
auth: {
|
|
185
|
-
me: () =>
|
|
185
|
+
me: async () => {
|
|
186
|
+
const s = await auth.fetchMe();
|
|
187
|
+
if (s.status === 'unknown') throw s.error; // 모름(서버 다운·오프라인) → renderUnavailable
|
|
188
|
+
return s.status === 'authenticated' ? s.user : null; // null → 미인증 → renderLogin
|
|
189
|
+
},
|
|
186
190
|
renderLogin: ({ root, onSuccess }) => renderLoginPage(root, auth, onSuccess),
|
|
191
|
+
renderUnavailable: ({ root, retry }) => renderOfflinePage(root, retry),
|
|
187
192
|
onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
188
193
|
},
|
|
189
194
|
routes: [ /* ... */ ],
|
|
@@ -193,6 +198,7 @@ app.user; // 인증된 현재 사용자(미인증/미사용 시 undefined)
|
|
|
193
198
|
```
|
|
194
199
|
|
|
195
200
|
- `me()` 가 값을 반환하면 셸 로드, `null`/`undefined` 면 `renderLogin({ root, onSuccess })`.
|
|
201
|
+
- `me()` 가 **던지면** 세션을 «모름» 으로 보고 로그인 UI 를 그리지 않는다 — `renderUnavailable({ root, error, retry })` 를 그리고, 없으면 `app.load()` 가 그 오류로 실패한다. 서버가 잠깐 503 을 낸 것을 «미인증» 으로 돌려주면 로그인된 사용자가 로그인 화면으로 간다.
|
|
196
202
|
- 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 재로드되어 셸이 나타나고 로그인 UI 는 정리된다.
|
|
197
203
|
- `auth` 미지정 시 완전히 하위호환(게이트 없이 기존대로 로드).
|
|
198
204
|
|
package/dist/App.d.ts
CHANGED
package/dist/App.js
CHANGED
|
@@ -46,18 +46,30 @@ var l = class o {
|
|
|
46
46
|
await t.init(a.i18n);
|
|
47
47
|
}
|
|
48
48
|
if (a.auth) {
|
|
49
|
-
let e =
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
let e = a.root || document.body, t = () => {
|
|
50
|
+
this.load(a);
|
|
51
|
+
}, n;
|
|
52
|
+
try {
|
|
53
|
+
n = await a.auth.me();
|
|
54
|
+
} catch (n) {
|
|
55
|
+
if (!a.auth.renderUnavailable) throw n;
|
|
56
|
+
let r = a.auth.renderUnavailable({
|
|
52
57
|
root: e,
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
}
|
|
58
|
+
error: n,
|
|
59
|
+
retry: t
|
|
56
60
|
});
|
|
57
|
-
|
|
61
|
+
r && (this._gateTeardown = r);
|
|
58
62
|
return;
|
|
59
63
|
}
|
|
60
|
-
|
|
64
|
+
if (n == null) {
|
|
65
|
+
let n = a.auth.renderLogin({
|
|
66
|
+
root: e,
|
|
67
|
+
onSuccess: t
|
|
68
|
+
});
|
|
69
|
+
n && (this._gateTeardown = n);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
this._user = n, await a.auth.onAuthenticated?.(n);
|
|
61
73
|
}
|
|
62
74
|
let o = a.root || document.body;
|
|
63
75
|
this._layout = await this.createLayout(o, a.layout), this._screen = new e({
|
|
@@ -77,7 +89,7 @@ var l = class o {
|
|
|
77
89
|
});
|
|
78
90
|
}
|
|
79
91
|
unload() {
|
|
80
|
-
this.
|
|
92
|
+
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;
|
|
81
93
|
}
|
|
82
94
|
navigate(e) {
|
|
83
95
|
this._router?.go(e);
|
|
@@ -3,7 +3,7 @@ type ElementParts = 'host' | 'label' | 'value' | 'unit' | 'trend';
|
|
|
3
3
|
export type InfoFieldFormat = 'number' | 'currency' | 'date';
|
|
4
4
|
export type InfoFieldSize = 'default' | 'lg';
|
|
5
5
|
export type InfoFieldTrend = 'up' | 'down' | 'flat';
|
|
6
|
-
export type InfoFieldTone = 'positive' | 'negative' | 'neutral';
|
|
6
|
+
export type InfoFieldTone = 'positive' | 'negative' | 'warning' | 'neutral';
|
|
7
7
|
/** 값이 "아직 없음"인가 — `0` 과 `false` 는 **값이다**. */
|
|
8
8
|
export declare function isBlank(v: unknown): boolean;
|
|
9
9
|
/**
|
|
@@ -89,8 +89,11 @@ export declare class InfoField extends StyledElement<ElementParts> {
|
|
|
89
89
|
* direction-to-sentiment mapping (e.g. a falling "open tickets" count is `positive`).
|
|
90
90
|
*
|
|
91
91
|
* Colors the value text itself, independent of `trend` — a static "balance due" figure can be
|
|
92
|
-
* toned `negative` without a trend arrow. `
|
|
93
|
-
*
|
|
92
|
+
* toned `negative` without a trend arrow. `warning` is for "needs attention, not wrong" (a
|
|
93
|
+
* deadline coming up, a rate under target but within limits) and is never inferred from `trend`.
|
|
94
|
+
* `neutral` has no visual effect on the value text (it already renders at full strength; only
|
|
95
|
+
* `positive`/`negative`/`warning` stand out from it). Colors come from the theme's
|
|
96
|
+
* `--u-success|danger|warning-color-strong`.
|
|
94
97
|
*/
|
|
95
98
|
tone?: InfoFieldTone;
|
|
96
99
|
private get hasSlotted();
|
|
@@ -73,6 +73,10 @@ var t = e`
|
|
|
73
73
|
color: var(--u-danger-color-strong, #C62828);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
.value.tone-warning {
|
|
77
|
+
color: var(--u-warning-color-strong, #8A4A00);
|
|
78
|
+
}
|
|
79
|
+
|
|
76
80
|
.trend {
|
|
77
81
|
font-size: var(--u-text-caption-size, 12px);
|
|
78
82
|
margin-top: var(--u-space-3xs, 2px);
|
|
@@ -86,6 +90,10 @@ var t = e`
|
|
|
86
90
|
color: var(--u-danger-color-strong, #C62828);
|
|
87
91
|
}
|
|
88
92
|
|
|
93
|
+
.trend.tone-warning {
|
|
94
|
+
color: var(--u-warning-color-strong, #8A4A00);
|
|
95
|
+
}
|
|
96
|
+
|
|
89
97
|
.trend.tone-neutral {
|
|
90
98
|
color: var(--u-txt-color-weak, #616161);
|
|
91
99
|
}
|
|
@@ -34,8 +34,10 @@ var t = e`
|
|
|
34
34
|
}
|
|
35
35
|
button[selected] {
|
|
36
36
|
/* 패널 위 텍스트라 강조색을 그대로 쓰면 대비가 부족하다(blue-600 = 3.68:1).
|
|
37
|
-
활성 배경과 같은 파생식으로 한 단 어둡게 해 AA 를 넘긴다(4.85:1)
|
|
38
|
-
|
|
37
|
+
기본은 활성 배경과 같은 파생식으로 한 단 어둡게 해 AA 를 넘긴다(4.85:1) — 활성 면이 «진한» 색이라는 전제다.
|
|
38
|
+
연한 활성 면(+ 진한 글자)을 쓰는 테마에서는 그 면을 15% 어둡게 한 색이 패널 위에서 1.6:1 로 묻혔다.
|
|
39
|
+
그런 테마는 --app-sidebar-group-active-fg 를 «패널 위 강조 글자» 로 정한다. */
|
|
40
|
+
color: var(--app-sidebar-group-active-fg, color-mix(in srgb, var(--app-sidebar-active-bg, var(--u-primary-color, #1976D2)) 85%, black));
|
|
39
41
|
}
|
|
40
42
|
button:hover {
|
|
41
43
|
color: var(--u-txt-color-hover, #1565C0);
|
|
@@ -52,7 +54,8 @@ var t = e`
|
|
|
52
54
|
.icon {
|
|
53
55
|
flex-shrink: 0;
|
|
54
56
|
color: inherit;
|
|
55
|
-
|
|
57
|
+
/* 링크와 같은 토큰 — 한 레일의 아이콘은 한 크기다(위계는 들여쓰기·캐럿·레일이 진다). */
|
|
58
|
+
font-size: var(--app-sidebar-icon-size, 20px);
|
|
56
59
|
}
|
|
57
60
|
|
|
58
61
|
/*
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { nothing, PropertyValues } from 'lit';
|
|
2
2
|
import { UProgressBar } from '@iyulab/components/dist/components/progress-bar/UProgressBar.js';
|
|
3
|
-
import { RouteContext } from '@iyulab/router';
|
|
4
3
|
import { StyledElement } from '../internals/StyledElement.js';
|
|
5
4
|
import { SidebarLayoutConfig, SidebarState, SidebarParts } from './SidebarLayout.types';
|
|
6
5
|
export declare class SidebarLayout extends StyledElement<SidebarParts> {
|
|
@@ -38,8 +37,12 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
|
|
|
38
37
|
* `NotSupportedError`이고, `connectedCallback`은 재연결마다 다시 돈다.
|
|
39
38
|
*/
|
|
40
39
|
private readonly internals;
|
|
41
|
-
/**
|
|
42
|
-
|
|
40
|
+
/**
|
|
41
|
+
* 현재 라우트 경로(`RouteContext.path` 형식) — 메뉴의 «현재 항목» 판정 원천.
|
|
42
|
+
* 붙을 때 지금 위치로 시작한다: 셸이 라우트 결과물로 그려지면(React 중첩 라우트의 부모) 첫 `route-begin` 은
|
|
43
|
+
* 셸이 붙기 **전에** 지나가, 주소창 진입·새로 고침 직후 현재 항목이 하나도 없었다.
|
|
44
|
+
*/
|
|
45
|
+
private currentPath;
|
|
43
46
|
/**
|
|
44
47
|
* 라우트 컨텐츠의 실제 스크롤 컨테이너(섀도 DOM `[part="main"]`). `scrollTop`을 읽어
|
|
45
48
|
* 위치를 저장하거나, 써서 복원한다 — `part="main"`은 스타일링용 CSS 훅일 뿐 JS 접근
|
|
@@ -11,28 +11,29 @@ import "../components/SidebarLink.js";
|
|
|
11
11
|
import "../components/SidebarGroup.js";
|
|
12
12
|
import "../components/SidebarButton.js";
|
|
13
13
|
import { styles as c } from "./SidebarLayout.styles.js";
|
|
14
|
-
import {
|
|
15
|
-
import {
|
|
14
|
+
import { currentRoutePath as l } from "@iyulab/router";
|
|
15
|
+
import { html as u, nothing as d } from "lit";
|
|
16
|
+
import { customElement as f, property as p, query as m, state as h } from "lit/decorators.js";
|
|
16
17
|
import "@iyulab/components/dist/components/icon/UIcon.js";
|
|
17
18
|
import "@iyulab/components/dist/components/button/UButton.js";
|
|
18
|
-
import { unsafeHTML as
|
|
19
|
-
import { repeat as
|
|
19
|
+
import { unsafeHTML as g } from "lit/directives/unsafe-html.js";
|
|
20
|
+
import { repeat as _ } from "lit/directives/repeat.js";
|
|
20
21
|
import "@iyulab/components/dist/components/popover/UPopover.js";
|
|
21
22
|
import "@iyulab/components/dist/components/menu/UMenu.js";
|
|
22
23
|
import "@iyulab/components/dist/components/menu-item/UMenuItem.js";
|
|
23
|
-
import { createDevWarner as
|
|
24
|
-
import { isFocusCandidate as
|
|
25
|
-
import { OverlayManager as
|
|
26
|
-
import { UProgressBar as
|
|
24
|
+
import { createDevWarner as v } from "@iyulab/components/dist/utilities/devWarning.js";
|
|
25
|
+
import { isFocusCandidate as y, querySelectorDeep as b } from "@iyulab/components/dist/utilities/elements.js";
|
|
26
|
+
import { OverlayManager as x } from "@iyulab/components/dist/utilities/OverlayManager.js";
|
|
27
|
+
import { UProgressBar as S } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
|
|
27
28
|
//#region src/layouts/SidebarLayout.ts
|
|
28
|
-
var
|
|
29
|
-
function
|
|
29
|
+
var C, w = v("@iyulab/modern-app"), T = 0;
|
|
30
|
+
function E(e) {
|
|
30
31
|
let t = e.tagName;
|
|
31
32
|
if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
|
|
32
33
|
let n = e.getAttribute("role");
|
|
33
34
|
return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
|
|
34
35
|
}
|
|
35
|
-
var
|
|
36
|
+
var D = /* @__PURE__ */ new Set([
|
|
36
37
|
"button",
|
|
37
38
|
"link",
|
|
38
39
|
"checkbox",
|
|
@@ -45,27 +46,27 @@ var E = /* @__PURE__ */ new Set([
|
|
|
45
46
|
"tab",
|
|
46
47
|
"treeitem"
|
|
47
48
|
]);
|
|
48
|
-
function
|
|
49
|
+
function O(e) {
|
|
49
50
|
let t = e.tagName;
|
|
50
|
-
return t === "BUTTON" || t === "SUMMARY" || t === "A" && e.hasAttribute("href") ? !0 :
|
|
51
|
+
return t === "BUTTON" || t === "SUMMARY" || t === "A" && e.hasAttribute("href") ? !0 : D.has(e.getAttribute("role") ?? "");
|
|
51
52
|
}
|
|
52
|
-
function
|
|
53
|
+
function k(e) {
|
|
53
54
|
let t = getComputedStyle(e);
|
|
54
55
|
return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
|
|
55
56
|
}
|
|
56
|
-
function
|
|
57
|
+
function A(e, t) {
|
|
57
58
|
for (let n of e) if (n instanceof HTMLElement) {
|
|
58
|
-
if (
|
|
59
|
+
if (k(n)) return n === t;
|
|
59
60
|
if (n === t) break;
|
|
60
61
|
}
|
|
61
62
|
return !0;
|
|
62
63
|
}
|
|
63
|
-
function
|
|
64
|
+
function j() {
|
|
64
65
|
let e = document.activeElement;
|
|
65
66
|
for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
|
|
66
67
|
return e;
|
|
67
68
|
}
|
|
68
|
-
function
|
|
69
|
+
function M(e, t) {
|
|
69
70
|
let n = t;
|
|
70
71
|
for (; n;) {
|
|
71
72
|
if (n === e) return !0;
|
|
@@ -73,18 +74,18 @@ function j(e, t) {
|
|
|
73
74
|
}
|
|
74
75
|
return !1;
|
|
75
76
|
}
|
|
76
|
-
var
|
|
77
|
+
var N = class extends i {
|
|
77
78
|
static {
|
|
78
|
-
|
|
79
|
+
C = this;
|
|
79
80
|
}
|
|
80
81
|
constructor(...t) {
|
|
81
|
-
super(...t), this.state = "default", this.counts = {}, this.locale = "", this.hasOverlay = !1, this.openMenu = null, this.menuIds = /* @__PURE__ */ new WeakMap(), this.hasNotice = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.
|
|
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.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.currentPath = null, this.unsizedWarnKey = `unsized:${++T}`, 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 = () => {
|
|
82
83
|
let t = this.screenSize ?? e.screen ?? "large";
|
|
83
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);
|
|
84
85
|
}, this.handleBackdropClick = () => {
|
|
85
86
|
this.state = "slim";
|
|
86
87
|
}, this.handleRouteBegin = (e) => {
|
|
87
|
-
this.progressBarEl.setAttribute("visible", ""), this.progressBarEl.value = 0, this.state === "modal" && (this.state = "slim"), this.state === "mobile-open" && (this.state = "mobile"), this.
|
|
88
|
+
this.progressBarEl.setAttribute("visible", ""), this.progressBarEl.value = 0, this.state === "modal" && (this.state = "slim"), this.state === "mobile-open" && (this.state = "mobile"), this.currentPath = e.context.path;
|
|
88
89
|
}, this.handleRouteProgress = (e) => {
|
|
89
90
|
this.progressBarEl.value = e.progress;
|
|
90
91
|
}, this.handleRouteDone = (e) => {
|
|
@@ -104,7 +105,7 @@ var M = class extends i {
|
|
|
104
105
|
}, this.handleOverlaySlotChange = (e) => {
|
|
105
106
|
let t = o(e.target);
|
|
106
107
|
if (t && !this.hasOverlay) {
|
|
107
|
-
let e =
|
|
108
|
+
let e = j();
|
|
108
109
|
this.overlayTrigger = e && e !== document.body ? e : null;
|
|
109
110
|
}
|
|
110
111
|
this.hasOverlay = t;
|
|
@@ -118,9 +119,9 @@ var M = class extends i {
|
|
|
118
119
|
}, this._handleMainKeydown = (e) => {
|
|
119
120
|
if (e.defaultPrevented) return;
|
|
120
121
|
let t = e.composedPath()[0];
|
|
121
|
-
if (t instanceof HTMLElement &&
|
|
122
|
+
if (t instanceof HTMLElement && E(t) || e.key === " " && t instanceof HTMLElement && O(t)) return;
|
|
122
123
|
let n = this.shadowRoot?.querySelector(".main");
|
|
123
|
-
if (!n || !
|
|
124
|
+
if (!n || !A(e.composedPath(), n)) return;
|
|
124
125
|
let r = n.clientHeight;
|
|
125
126
|
switch (e.key) {
|
|
126
127
|
case " ":
|
|
@@ -160,16 +161,16 @@ var M = class extends i {
|
|
|
160
161
|
return this.shadowRoot?.querySelector(".main") ?? null;
|
|
161
162
|
}
|
|
162
163
|
connectedCallback() {
|
|
163
|
-
super.connectedCallback(), window.addEventListener("route-begin", this.handleRouteBegin), window.addEventListener("route-done", this.handleRouteDone), window.addEventListener("route-progress", this.handleRouteProgress), window.addEventListener("route-error", this.handleRouteError), window.addEventListener("screen-resize", this.handleScreenResize);
|
|
164
|
+
super.connectedCallback(), this.currentPath = l(), window.addEventListener("route-begin", this.handleRouteBegin), window.addEventListener("route-done", this.handleRouteDone), window.addEventListener("route-progress", this.handleRouteProgress), window.addEventListener("route-error", this.handleRouteError), window.addEventListener("screen-resize", this.handleScreenResize);
|
|
164
165
|
}
|
|
165
166
|
disconnectedCallback() {
|
|
166
|
-
window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize),
|
|
167
|
+
window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize), x.closeLayer(this), super.disconnectedCallback();
|
|
167
168
|
}
|
|
168
169
|
willUpdate(e) {
|
|
169
170
|
super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
|
|
170
171
|
}
|
|
171
172
|
updated(e) {
|
|
172
|
-
super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (
|
|
173
|
+
super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (x.openLayer(this, this.handleOverlayEscape), this.focusOverlay()) : e.get("hasOverlay") === !0 && (x.closeLayer(this), this.restoreOverlayFocus()));
|
|
173
174
|
}
|
|
174
175
|
firstUpdated(e) {
|
|
175
176
|
super.firstUpdated(e), this.warnIfUnsized();
|
|
@@ -178,11 +179,11 @@ var M = class extends i {
|
|
|
178
179
|
process.env.NODE_ENV !== "production" && requestAnimationFrame(() => {
|
|
179
180
|
if (!this.isConnected) return;
|
|
180
181
|
let e = this.getBoundingClientRect().height;
|
|
181
|
-
e >= 200 ||
|
|
182
|
+
e >= 200 || w(this.unsizedWarnKey, `u-sidebar-layout is only ${Math.round(e)}px tall — its height: 100% found no sized ancestor, so the shell sits at its own chrome height and the route area has almost no room. Give the root element a height (e.g. #app { height: 100vh } — app.load() does this for document.body).`);
|
|
182
183
|
});
|
|
183
184
|
}
|
|
184
185
|
render() {
|
|
185
|
-
return this.config ?
|
|
186
|
+
return this.config ? u`
|
|
186
187
|
<!-- 반복 영역 건너뛰기(WCAG 2.4.1) — 셸의 첫 Tab. 포커스될 때만 보인다. -->
|
|
187
188
|
<a class="skip-link" part="skip-link" href="#" @click=${this.handleSkipLinkClick}>
|
|
188
189
|
${a(this.locale || void 0).skipToContent}
|
|
@@ -224,13 +225,13 @@ var M = class extends i {
|
|
|
224
225
|
|
|
225
226
|
<!-- Sidebar Navigation Menu -->
|
|
226
227
|
<nav class="sidebar-main" part="sidebar-main" scrollable
|
|
227
|
-
aria-label=${this.config.mainAriaLabel ??
|
|
228
|
-
${
|
|
228
|
+
aria-label=${this.config.mainAriaLabel ?? d}>
|
|
229
|
+
${_(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
229
230
|
</nav>
|
|
230
231
|
|
|
231
232
|
<!-- Sidebar Footer -->
|
|
232
233
|
<div class="sidebar-footer" part="sidebar-footer">
|
|
233
|
-
${
|
|
234
|
+
${_(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
234
235
|
</div>
|
|
235
236
|
</aside>
|
|
236
237
|
|
|
@@ -241,8 +242,8 @@ var M = class extends i {
|
|
|
241
242
|
|
|
242
243
|
<div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
|
|
243
244
|
<div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
|
|
244
|
-
role=${this.config.noticesAriaLabel ? "region" :
|
|
245
|
-
aria-label=${this.config.noticesAriaLabel ??
|
|
245
|
+
role=${this.config.noticesAriaLabel ? "region" : d}
|
|
246
|
+
aria-label=${this.config.noticesAriaLabel ?? d}>
|
|
246
247
|
<slot name="notice" @slotchange=${this.handleNoticeSlotChange}></slot>
|
|
247
248
|
</div>
|
|
248
249
|
<slot></slot>
|
|
@@ -263,13 +264,13 @@ var M = class extends i {
|
|
|
263
264
|
<div class="backdrop" ?hidden="${this.state !== "modal"}"
|
|
264
265
|
@click="${this.handleBackdropClick}"
|
|
265
266
|
></div>
|
|
266
|
-
` :
|
|
267
|
+
` : d;
|
|
267
268
|
}
|
|
268
269
|
renderMenu(e) {
|
|
269
270
|
let t = this.menuIds.get(e);
|
|
270
|
-
t || (t = `u-sidebar-menu-${++
|
|
271
|
+
t || (t = `u-sidebar-menu-${++C.menuSeq}`, this.menuIds.set(e, t));
|
|
271
272
|
let n = t, r = this.state === "mobile" || this.state === "mobile-open" ? "bottom-start" : "right-start";
|
|
272
|
-
return
|
|
273
|
+
return u`
|
|
273
274
|
<u-sidebar-button
|
|
274
275
|
id=${n}
|
|
275
276
|
haspopup="menu"
|
|
@@ -286,9 +287,9 @@ var M = class extends i {
|
|
|
286
287
|
@hide=${(e) => this.handleMenuHide(e, n)}
|
|
287
288
|
@pick=${(t) => this.handleMenuPick(t, e)}>
|
|
288
289
|
<u-menu>
|
|
289
|
-
${
|
|
290
|
+
${_(e.items, (e, t) => t, (e, t) => u`
|
|
290
291
|
<u-menu-item value=${String(t)} ?disabled=${e.disabled === !0}>
|
|
291
|
-
${e.icon ?
|
|
292
|
+
${e.icon ? u`<u-icon slot="prefix" .lib=${e.lib} .name=${e.icon}></u-icon>` : d}
|
|
292
293
|
${e.label}
|
|
293
294
|
</u-menu-item>
|
|
294
295
|
`)}
|
|
@@ -302,16 +303,16 @@ var M = class extends i {
|
|
|
302
303
|
}
|
|
303
304
|
handleMenuHide(e, t) {
|
|
304
305
|
this.openMenu === t && (this.openMenu = null);
|
|
305
|
-
let n = e.currentTarget, r =
|
|
306
|
-
r &&
|
|
306
|
+
let n = e.currentTarget, r = j();
|
|
307
|
+
r && M(n, r) && this.shadowRoot?.querySelector(`#${t}`)?.focus();
|
|
307
308
|
}
|
|
308
309
|
renderItem(e) {
|
|
309
|
-
if (!e) return
|
|
310
|
+
if (!e) return d;
|
|
310
311
|
if (e.type === "html") {
|
|
311
312
|
let t = e.render(this.state);
|
|
312
|
-
return typeof t == "string" ?
|
|
313
|
+
return typeof t == "string" ? g(t) : u`${t}`;
|
|
313
314
|
}
|
|
314
|
-
if (e.type === "button") return
|
|
315
|
+
if (e.type === "button") return u`
|
|
315
316
|
<u-sidebar-button
|
|
316
317
|
?compact=${this.state === "slim"}
|
|
317
318
|
.icon="${e.icon}"
|
|
@@ -324,7 +325,7 @@ var M = class extends i {
|
|
|
324
325
|
if (e.type === "menu") return this.renderMenu(e);
|
|
325
326
|
if (e.type === "link") {
|
|
326
327
|
let t = this.isMatchedLink(e.pattern || e.href);
|
|
327
|
-
return
|
|
328
|
+
return u`
|
|
328
329
|
<u-sidebar-link
|
|
329
330
|
?compact=${this.state === "slim"}
|
|
330
331
|
?selected=${t}
|
|
@@ -340,18 +341,18 @@ var M = class extends i {
|
|
|
340
341
|
></u-sidebar-link>
|
|
341
342
|
`;
|
|
342
343
|
}
|
|
343
|
-
if (e.type === "section") return
|
|
344
|
+
if (e.type === "section") return u`
|
|
344
345
|
<u-sidebar-section
|
|
345
346
|
?compact=${this.state === "slim"}
|
|
346
347
|
.mainTitle="${e.title}"
|
|
347
348
|
.subTitle="${e.subTitle}"
|
|
348
349
|
.styles="${e.styles}">
|
|
349
|
-
${
|
|
350
|
+
${_(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
350
351
|
</u-sidebar-section>
|
|
351
352
|
`;
|
|
352
353
|
if (e.type === "group") {
|
|
353
354
|
let t = e.items.some((e) => this.isMatchedLink(e.pattern || e.href));
|
|
354
|
-
return
|
|
355
|
+
return u`
|
|
355
356
|
<u-sidebar-group
|
|
356
357
|
?compact=${this.state === "slim"}
|
|
357
358
|
?selected=${t}
|
|
@@ -360,11 +361,11 @@ var M = class extends i {
|
|
|
360
361
|
.lib="${e.lib}"
|
|
361
362
|
.label="${e.label}"
|
|
362
363
|
.styles="${e.styles}">
|
|
363
|
-
${
|
|
364
|
+
${_(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
364
365
|
</u-sidebar-group>
|
|
365
366
|
`;
|
|
366
367
|
}
|
|
367
|
-
return
|
|
368
|
+
return d;
|
|
368
369
|
}
|
|
369
370
|
static {
|
|
370
371
|
this.DEFAULT_ICONS = {
|
|
@@ -376,30 +377,30 @@ var M = class extends i {
|
|
|
376
377
|
};
|
|
377
378
|
}
|
|
378
379
|
icon(e) {
|
|
379
|
-
return this.config?.icons?.[e] ??
|
|
380
|
+
return this.config?.icons?.[e] ?? C.DEFAULT_ICONS[e];
|
|
380
381
|
}
|
|
381
382
|
renderLogo() {
|
|
382
383
|
let e = this.config?.logo, t = this.config?.title ?? "Home";
|
|
383
|
-
if (!e || typeof e == "string") return e ?
|
|
384
|
+
if (!e || typeof e == "string") return e ? u`
|
|
384
385
|
<u-link class="logo-link" aria-label=${t}>
|
|
385
386
|
<u-icon class="logo"
|
|
386
387
|
.name="${e}"
|
|
387
388
|
.fallback=${s}
|
|
388
389
|
></u-icon>
|
|
389
390
|
</u-link>
|
|
390
|
-
` :
|
|
391
|
+
` : d;
|
|
391
392
|
if (typeof e == "function") {
|
|
392
393
|
let n = e(this.state);
|
|
393
|
-
return
|
|
394
|
+
return u`
|
|
394
395
|
<u-link class="logo-link" aria-label=${t}>
|
|
395
396
|
<span class="logo">
|
|
396
|
-
${typeof n == "string" ?
|
|
397
|
+
${typeof n == "string" ? g(n) : n}
|
|
397
398
|
</span>
|
|
398
399
|
</u-link>
|
|
399
400
|
`;
|
|
400
401
|
}
|
|
401
|
-
return
|
|
402
|
-
<u-link class="logo-link" .href=${e.href} aria-label=${e.alt ?
|
|
402
|
+
return u`
|
|
403
|
+
<u-link class="logo-link" .href=${e.href} aria-label=${e.alt ? d : t}>
|
|
403
404
|
<img class="logo"
|
|
404
405
|
src="${e.src}"
|
|
405
406
|
alt="${e.alt ?? ""}"
|
|
@@ -413,14 +414,14 @@ var M = class extends i {
|
|
|
413
414
|
async focusRoute(e) {
|
|
414
415
|
let t = this.routeContent();
|
|
415
416
|
if (await Promise.all(t.map((e) => e.updateComplete)), this.hasOverlay || !e.isConnected) return;
|
|
416
|
-
let n =
|
|
417
|
-
n && t.some((e) =>
|
|
417
|
+
let n = j();
|
|
418
|
+
n && t.some((e) => M(e, n)) || this.focusContentStart(t, e);
|
|
418
419
|
}
|
|
419
420
|
focusContentStart(e, t) {
|
|
420
|
-
let n =
|
|
421
|
+
let n = b(e, "[autofocus]", y);
|
|
421
422
|
n?.focus();
|
|
422
|
-
let r =
|
|
423
|
-
n && r &&
|
|
423
|
+
let r = j();
|
|
424
|
+
n && r && M(n, r) || t.focus({ preventScroll: !0 });
|
|
424
425
|
}
|
|
425
426
|
overlayPanels() {
|
|
426
427
|
return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
|
|
@@ -428,19 +429,19 @@ var M = class extends i {
|
|
|
428
429
|
async focusOverlay() {
|
|
429
430
|
let e = this.overlayPanels();
|
|
430
431
|
if (await Promise.all(e.map((e) => e.updateComplete)), !this.hasOverlay) return;
|
|
431
|
-
let t =
|
|
432
|
-
t && e.some((e) =>
|
|
432
|
+
let t = j();
|
|
433
|
+
t && e.some((e) => M(e, t)) || (b(e, "[autofocus]", y) ?? b(e, "input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider", y) ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
|
|
433
434
|
}
|
|
434
435
|
restoreOverlayFocus() {
|
|
435
436
|
let e = this.overlayTrigger;
|
|
436
437
|
this.overlayTrigger = null;
|
|
437
|
-
let t =
|
|
438
|
+
let t = j();
|
|
438
439
|
t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
|
|
439
440
|
}
|
|
440
441
|
};
|
|
441
|
-
r([
|
|
442
|
+
r([p({
|
|
442
443
|
type: String,
|
|
443
444
|
reflect: !0
|
|
444
|
-
}), n("design:type", Object)],
|
|
445
|
+
}), n("design:type", Object)], N.prototype, "state", void 0), r([p({ type: Object }), n("design:type", Object)], N.prototype, "config", void 0), r([p({ type: Object }), n("design:type", typeof Record > "u" ? Object : Record)], N.prototype, "counts", void 0), r([p({ type: String }), n("design:type", Object)], N.prototype, "locale", void 0), r([m("u-progress-bar"), n("design:type", S === void 0 ? Object : S)], N.prototype, "progressBarEl", void 0), r([h(), n("design:type", Object)], N.prototype, "hasOverlay", void 0), r([h(), n("design:type", Object)], N.prototype, "openMenu", void 0), r([h(), n("design:type", Object)], N.prototype, "hasNotice", void 0), r([h(), n("design:type", Object)], N.prototype, "currentPath", void 0), N = C = r([f("u-sidebar-layout")], N);
|
|
445
446
|
//#endregion
|
|
446
|
-
export {
|
|
447
|
+
export { N as SidebarLayout };
|
|
@@ -102,15 +102,21 @@ export interface AppConfig {
|
|
|
102
102
|
i18n?: I18nInitOptions;
|
|
103
103
|
/**
|
|
104
104
|
* 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()` 로 세션을 판정하여
|
|
105
|
-
* 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다.
|
|
105
|
+
* 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다. `me()` 가 던지면(세션 모름)
|
|
106
|
+
* `renderUnavailable` 을 그린다.
|
|
106
107
|
* @see AuthGateConfig
|
|
107
108
|
* @example
|
|
108
109
|
* ```typescript
|
|
109
110
|
* app.load({
|
|
110
111
|
* layout: { type: 'sidebar', ... },
|
|
111
112
|
* auth: {
|
|
112
|
-
* me: () =>
|
|
113
|
+
* me: async () => { // @iyulab/enterprise createAuthClient
|
|
114
|
+
* const s = await authClient.fetchMe();
|
|
115
|
+
* if (s.status === 'unknown') throw s.error; // 모름 — 로그인이 아니라 renderUnavailable
|
|
116
|
+
* return s.status === 'authenticated' ? s.user : null; // null → 미인증
|
|
117
|
+
* },
|
|
113
118
|
* renderLogin: ({ root, onSuccess }) => renderLoginPage(root, onSuccess),
|
|
119
|
+
* renderUnavailable: ({ root, retry }) => renderOfflinePage(root, retry),
|
|
114
120
|
* onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
115
121
|
* },
|
|
116
122
|
* routes: [ ... ],
|
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을 판정한다:
|
|
5
5
|
* `me()` 가 값을 반환하면 인증된 것으로 보고 셸을 로드하고, `null`/`undefined` 면
|
|
6
6
|
* 미인증으로 보고 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
|
|
7
|
-
* 호출하면 앱이 (재)로드되어 셸이 나타난다.
|
|
7
|
+
* 호출하면 앱이 (재)로드되어 셸이 나타난다. `me()` 가 **던지면** 세션을 «모름»(서버 다운·오프라인)
|
|
8
|
+
* 으로 보고 로그인 UI 를 그리지 않는다 — `renderUnavailable` 이 있으면 그것을, 없으면 `load` 가 그
|
|
9
|
+
* 오류로 실패한다.
|
|
8
10
|
*
|
|
9
11
|
* 프레임워크는 인증의 **오케스트레이션**(판정 → 분기 → 재로드)만 소유한다. 세션 조회/로그인
|
|
10
12
|
* 자체(HTTP)와 사용자·권한 형태, 세션-중 401 처리는 앱/`@iyulab/enterprise` 가 소유한다.
|
|
@@ -15,9 +17,19 @@ export interface AuthGateContext {
|
|
|
15
17
|
/** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는 사용자를 반환해야 한다). */
|
|
16
18
|
onSuccess: () => void;
|
|
17
19
|
}
|
|
20
|
+
/** `renderUnavailable` 이 받는 맥락 — 세션을 확인하지 못했을 때. */
|
|
21
|
+
export interface AuthGateUnavailableContext {
|
|
22
|
+
/** 안내 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
|
|
23
|
+
root: Element;
|
|
24
|
+
/** `me()` 가 던진 값. */
|
|
25
|
+
error: unknown;
|
|
26
|
+
/** 다시 확인한다 — 앱이 (재)로드되어 `me()` 를 다시 부른다(재시도 버튼·`online` 이벤트에서). */
|
|
27
|
+
retry: () => void;
|
|
28
|
+
}
|
|
18
29
|
export interface AuthGateConfig {
|
|
19
30
|
/**
|
|
20
|
-
* 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다.
|
|
31
|
+
* 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다. **세션을 확인하지
|
|
32
|
+
* 못했으면 던진다**(서버 다운·오프라인) — 그것을 `null` 로 돌려주면 로그인된 사용자가 로그인 화면으로 간다.
|
|
21
33
|
* 동기/비동기 모두 허용한다.
|
|
22
34
|
*/
|
|
23
35
|
me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
|
|
@@ -26,6 +38,11 @@ export interface AuthGateConfig {
|
|
|
26
38
|
* 정리 함수를 반환하면 앱 로드/`unload` 시 호출되어 로그인 UI 를 제거한다.
|
|
27
39
|
*/
|
|
28
40
|
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
41
|
+
/**
|
|
42
|
+
* `me()` 가 던졌을 때(세션 모름) `context.root` 에 안내 UI 를 그린다(선택). `context.retry()` 로 다시
|
|
43
|
+
* 확인한다. 정리 함수를 반환하면 앱 로드/`unload` 시 호출된다. 없으면 `load` 가 `me()` 의 오류로 실패한다.
|
|
44
|
+
*/
|
|
45
|
+
renderUnavailable?: (context: AuthGateUnavailableContext) => (() => void) | void;
|
|
29
46
|
/**
|
|
30
47
|
* 인증 성공 후, 앱 셸을 만들기 직전에 호출된다(선택).
|
|
31
48
|
* 사용자별 라우트/메뉴 필터 등 셸 구성 전 처리를 여기서 한다.
|
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.39.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"web-framework",
|
|
@@ -60,7 +60,7 @@
|
|
|
60
60
|
"test:react-types": "tsc -p tsconfig.react-smoke.json"
|
|
61
61
|
},
|
|
62
62
|
"dependencies": {
|
|
63
|
-
"@iyulab/router": "^0.
|
|
63
|
+
"@iyulab/router": "^0.20.0",
|
|
64
64
|
"i18next": "^26.3.6",
|
|
65
65
|
"lit": "^3.3.3"
|
|
66
66
|
},
|
|
@@ -228,7 +228,11 @@ The framework owns only the orchestration (check → branch → reload) — sess
|
|
|
228
228
|
|
|
229
229
|
```typescript
|
|
230
230
|
interface AuthGateConfig {
|
|
231
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* Resolve the current session. Return a value for authenticated, `null`/`undefined` for not.
|
|
233
|
+
* **Throw when the session could not be checked** (server down, offline) — returning `null`
|
|
234
|
+
* there would send a signed-in user to the login screen.
|
|
235
|
+
*/
|
|
232
236
|
me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
|
|
233
237
|
|
|
234
238
|
/**
|
|
@@ -237,6 +241,14 @@ interface AuthGateConfig {
|
|
|
237
241
|
*/
|
|
238
242
|
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
239
243
|
|
|
244
|
+
/**
|
|
245
|
+
* Renders a "can't reach the server" view into `context.root` when `me()` throws. Call
|
|
246
|
+
* `context.retry()` to check again (a retry button, the `online` event). Return a cleanup
|
|
247
|
+
* function to have it called on app load/`unload`. Without it, `app.load()` rejects with
|
|
248
|
+
* `me()`'s error.
|
|
249
|
+
*/
|
|
250
|
+
renderUnavailable?: (context: AuthGateUnavailableContext) => (() => void) | void;
|
|
251
|
+
|
|
240
252
|
/** Called once authenticated, right before the app shell is built. */
|
|
241
253
|
onAuthenticated?: (user: unknown) => void | Promise<void>;
|
|
242
254
|
}
|
|
@@ -247,6 +259,15 @@ interface AuthGateContext {
|
|
|
247
259
|
/** Call on successful login — the app (re)loads and the shell appears. */
|
|
248
260
|
onSuccess: () => void;
|
|
249
261
|
}
|
|
262
|
+
|
|
263
|
+
interface AuthGateUnavailableContext {
|
|
264
|
+
/** Root element to render into (same as `AppConfig.root`, default `document.body`). */
|
|
265
|
+
root: Element;
|
|
266
|
+
/** What `me()` threw. */
|
|
267
|
+
error: unknown;
|
|
268
|
+
/** Check again — the app (re)loads and calls `me()` again. */
|
|
269
|
+
retry: () => void;
|
|
270
|
+
}
|
|
250
271
|
```
|
|
251
272
|
|
|
252
273
|
---
|
|
@@ -40,7 +40,7 @@ and those are different states of the business. So the component owns the rule.
|
|
|
40
40
|
| `size` | `'default'\|'lg'` | `'default'` | ✓ | `'lg'` renders the value at the `title` type-scale step — for dashboard KPI tiles composed inside `u-info-section` |
|
|
41
41
|
| `trend` | `'up'\|'down'\|'flat'` | — | | Trend direction; renders a trend indicator when set, alongside `trendLabel`; pair with trendLabel for an accessible name — trend alone conveys direction by color only |
|
|
42
42
|
| `trendLabel` | `string` | — | | Trend copy, e.g. `"+12% vs last month"` — wording is the consumer's responsibility |
|
|
43
|
-
| `tone` | `'positive'\|'negative'\|'neutral'` | — | | Explicit tone override; resolves from `trend` when unset (`up→positive`, `down→negative`, `flat`/unset→`neutral`) but always wins over inference. Colors the value text itself, independent of `trend` — a static figure (e.g. a balance due) can be toned `negative` with no trend arrow |
|
|
43
|
+
| `tone` | `'positive'\|'negative'\|'warning'\|'neutral'` | — | | Explicit tone override; resolves from `trend` when unset (`up→positive`, `down→negative`, `flat`/unset→`neutral`) but always wins over inference. Colors the value text itself, independent of `trend` — a static figure (e.g. a balance due) can be toned `negative` with no trend arrow. `warning` means "needs attention, not wrong" (a deadline coming up, a rate under target but within limits) and is never inferred |
|
|
44
44
|
|
|
45
45
|
⚠ `numeric` no longer right-aligns (it did before 0.25.0). A field is a label/value pair that owns
|
|
46
46
|
one grid cell, with no neighbouring figures to line up, so right-aligning only pushed the value
|
|
@@ -73,8 +73,8 @@ stay level:
|
|
|
73
73
|
blank="No downtime recorded for the failures"></u-info-field>
|
|
74
74
|
```
|
|
75
75
|
|
|
76
|
-
`tone` colors a blank value too, so a reason that needs attention takes `tone="
|
|
77
|
-
conditionally (`tone=${mttr == null ? '
|
|
76
|
+
`tone` colors a blank value too, so a reason that needs attention takes `tone="warning"`. Set it
|
|
77
|
+
conditionally (`tone=${mttr == null ? 'warning' : undefined}`) — otherwise the value is toned
|
|
78
78
|
as well once it exists.
|
|
79
79
|
|
|
80
80
|
## CSS Parts
|
|
@@ -99,3 +99,4 @@ as well once it exists.
|
|
|
99
99
|
| `--u-text-label-size` | Unit size |
|
|
100
100
|
| `--u-success-color-strong` | `tone="positive"` color (value and trend) |
|
|
101
101
|
| `--u-danger-color-strong` | `tone="negative"` color (value and trend) |
|
|
102
|
+
| `--u-warning-color-strong` | `tone="warning"` color (value and trend) |
|