@iyulab/modern-app 0.4.0 → 0.8.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 +86 -0
- package/README.md +66 -2
- package/dist/App.d.ts +4 -0
- package/dist/App.js +23 -3
- package/dist/_virtual/{_@oxc-project_runtime@0.138.0 → _@oxc-project_runtime@0.142.0}/helpers/esm/decorate.js +1 -1
- package/dist/_virtual/{_@oxc-project_runtime@0.138.0 → _@oxc-project_runtime@0.142.0}/helpers/esm/decorateMetadata.js +1 -1
- package/dist/components/SidebarButton.d.ts +2 -1
- package/dist/components/SidebarButton.js +2 -2
- package/dist/components/SidebarButton.styles.js +5 -5
- package/dist/components/SidebarGroup.d.ts +2 -1
- package/dist/components/SidebarGroup.js +2 -2
- package/dist/components/SidebarGroup.styles.js +12 -7
- package/dist/components/SidebarLink.d.ts +2 -1
- package/dist/components/SidebarLink.js +2 -2
- package/dist/components/SidebarLink.styles.js +15 -9
- package/dist/components/SidebarSection.d.ts +2 -1
- package/dist/components/SidebarSection.js +2 -2
- package/dist/components/SidebarSection.styles.js +7 -2
- package/dist/index.d.ts +3 -0
- package/dist/index.js +3 -2
- package/dist/internals/StyledElement.js +2 -2
- package/dist/layouts/SidebarLayout.js +41 -37
- package/dist/layouts/SidebarLayout.styles.js +18 -19
- package/dist/layouts/SidebarLayout.types.d.ts +9 -1
- package/dist/layouts/SidebarPermission.d.ts +15 -0
- package/dist/layouts/filterSidebarItems.d.ts +11 -0
- package/dist/layouts/filterSidebarItems.js +19 -0
- package/dist/types/AppConfigs.d.ts +47 -1
- package/dist/types/AuthConfig.d.ts +34 -0
- package/package.json +15 -9
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,91 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.8.0] - 2026-08-01
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- ★**셸을 브랜드 색으로 다시 칠할 수 있다** — 사이드바의 활성 메뉴 색이 파랑 프리미티브
|
|
8
|
+
(`--u-blue-600`)에 하드와이어돼 있었다. 소비자가 브랜드를 다른 색으로 잡아도 **화면에서
|
|
9
|
+
가장 눈에 띄는 좌측 메뉴만 파랑으로 남았고**, 이를 고치는 길이 둘 다 나빴다 — 팔레트를
|
|
10
|
+
덮으면 *진짜 파랑*이 필요한 배지·차트까지 오염되고(팔레트는 이름이 곧 값의 약속이다),
|
|
11
|
+
섀도 DOM 내부 CSS 를 뚫으면 구조 변경에 조용히 깨진다.
|
|
12
|
+
|
|
13
|
+
이제 셸은 역할 토큰을 경유한다. **재브랜딩이 한 줄이다**:
|
|
14
|
+
|
|
15
|
+
```css
|
|
16
|
+
:root { --u-primary-color: #7B1FA2; }
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
- **셸 표면 토큰을 소비자 계약으로 공개했다** — `--app-sidebar-{bg,fg,active-bg,active-fg,width,width-slim}`
|
|
20
|
+
· `--app-header-{bg,fg}`. **전부 선택**이며 미지정 시 역할 토큰에서 파생되므로, 아무것도
|
|
21
|
+
하지 않으면 브랜드를 따라오고 원하면 셸만 따로 조절한다. 문서: [docs/theme.md](docs/theme.md).
|
|
22
|
+
|
|
23
|
+
활성 메뉴의 hover 는 **별도 토큰이 아니라 파생**(`color-mix(… 85%, black)`)이다 — 한쪽만
|
|
24
|
+
덮은 소비자가 어긋난 색 쌍을 얻는 것이 이 계약이 막으려는 결함이기 때문이다.
|
|
25
|
+
|
|
26
|
+
- **브라우저 테스트 프로젝트** — 실제 렌더에서만 드러나는 것을 검증한다
|
|
27
|
+
(`npm run test:browser`). `color-mix` 계산과 커스텀 프로퍼티 캐스케이드는 jsdom 이
|
|
28
|
+
재현하지 못한다.
|
|
29
|
+
|
|
30
|
+
### Fixed
|
|
31
|
+
|
|
32
|
+
- **라우팅 실패 표시가 컴포넌트의 위험색 규약을 따르지 않던 문제 수정** — `u-progress-bar` 의
|
|
33
|
+
API 에 없는 `error` 속성을 세우고 색을 자체 CSS 로 덮고 있었다. 컴포넌트가 제공하는
|
|
34
|
+
`status="error"` 를 쓰도록 고쳤다 — 막대 색이 규약값으로 정정되고, 종전에는 정정되지 않던
|
|
35
|
+
버퍼 색도 함께 따라온다.
|
|
36
|
+
- **진행 표시 트랙 색 지정이 오타로 동작하지 않던 문제 수정** — `--progres-bar-track-color`
|
|
37
|
+
(`s` 누락)라 트랙이 투명해지지 않고 기본 회색으로 남아 있었다.
|
|
38
|
+
- **키보드 포커스 링이 테마를 따르지 않던 문제 수정** — 사이드바 그룹·버튼의 포커스 윤곽선이
|
|
39
|
+
하드코딩된 색(`#6666ff`)이었다. 브랜드와 무관했고 다크 테마에서도 그대로였다.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- ⚠**활성 메뉴 hover 배경이 미세하게 어두워진다** — 고정된 `--u-blue-700`(#1976D2) 대신
|
|
44
|
+
활성 색에서 파생하므로 기본 팔레트에서 #1A74C3 이 된다. 소비자가 브랜드를 바꿔도 hover 가
|
|
45
|
+
함께 따라오게 하려면 파생이어야 한다(고정 단은 따라오지 않는다).
|
|
46
|
+
- ⚠**사이드바 그룹의 강조 텍스트 색이 바뀐다** — 같은 파생식을 쓴다. 대비는 오히려
|
|
47
|
+
올라간다(4.60:1 → 4.85:1, WCAG AA 통과 유지).
|
|
48
|
+
- **사이드바 본문 텍스트가 `--u-neutral-800` 에서 시맨틱 토큰 `--u-txt-color` 로 바뀌었다.**
|
|
49
|
+
소비자의 텍스트 색 지정을 따르게 된다.
|
|
50
|
+
|
|
51
|
+
## [0.7.0] - 2026-08-01
|
|
52
|
+
|
|
53
|
+
### Fixed
|
|
54
|
+
|
|
55
|
+
- ★**토큰 시트를 로드하지 않으면 앱 셸이 깨지던 문제 수정** — 사이드바·레이아웃의 색 선언
|
|
56
|
+
**34곳**이 `@iyulab/components` 의 시트 토큰을 폴백 없이 참조하고 있었다. 시트가 없으면
|
|
57
|
+
`var(--u-*)` 가 무효가 되어 **그 선언이 통째로 버려지므로**, 메뉴 텍스트가 보이지 않고
|
|
58
|
+
사이드바 배경이 투명해졌다. 앱 셸이라 화면 전체가 영향을 받는다.
|
|
59
|
+
|
|
60
|
+
이제 각 참조가 기본 테마 값을 폴백으로 갖는다(`var(--u-txt-color, #212121)`).
|
|
61
|
+
**시트를 로드하는 경우 시각 변화는 없다** — 폴백은 시트가 없을 때만 평가된다.
|
|
62
|
+
리터럴이 업스트림 시트와 어긋나지 않도록 대조 테스트가 강제하며, 폴백은 손으로 쓰지 않고
|
|
63
|
+
생성한다.
|
|
64
|
+
|
|
65
|
+
⚠**폰트(`--u-font-base`)는 예외**다 — 폴백을 배선하지 않았다. 시트가 없으면 브라우저 기본
|
|
66
|
+
폰트로 대체되어 화면이 깨지지 않는다(색은 깨진다).
|
|
67
|
+
|
|
68
|
+
## [0.6.0] - 2026-07-03
|
|
69
|
+
|
|
70
|
+
### Added
|
|
71
|
+
- **`AppConfig.auth`** — 부팅 인증 게이트. 지정하면 앱 셸(레이아웃·라우터)을 만들기 전에 `me()` 로 세션을 판정하여, 인증 시 셸을 로드하고 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 (재)로드되어 셸이 나타난다. `onAuthenticated(user)` 로 셸 구성 직전 훅(권한 set 등)을 제공한다.
|
|
72
|
+
- 프레임워크는 인증 **오케스트레이션**(판정→분기→재로드)만 소유. 세션 조회/로그인 HTTP·사용자/권한 형태·세션-중 401 은 앱/`@iyulab/enterprise`(`createAuthClient`/`createODataService`)가 소유한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 부팅 게이트를 표준화.
|
|
73
|
+
- **`app.user`** getter — 인증 게이트 사용 시 인증된 현재 사용자(미인증/미사용이면 `undefined`).
|
|
74
|
+
- **사이드바 네이티브 권한 메뉴 필터** — 모든 메뉴 항목(link/section/group/button/html)에 `requirePermission?`/`requireAnyPermission?` 지원(공통 `SidebarPermissionGuard`). `SidebarLayoutConfig.hasPermission` 판정 함수를 주면 만족하지 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화. 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 도 export.
|
|
75
|
+
|
|
76
|
+
### Notes
|
|
77
|
+
- `auth`/`hasPermission` 미지정 시 동작은 완전히 하위호환(게이트·필터 없이 기존대로 셸 로드).
|
|
78
|
+
|
|
79
|
+
## [0.5.0] - 2026-07-02
|
|
80
|
+
|
|
81
|
+
### Added
|
|
82
|
+
- `AppConfig.enter` — global route guard, forwarded to the underlying `@iyulab/router` `Router`. Previously `@iyulab/router` already supported this via `RouterConfig.enter`, but `App.load()` never passed it through, so the global guard path was unreachable from modern-app. Return a `string` to redirect, `false` to cancel (403), `true`/nothing to proceed. See [docs/routing.md#authentication--guards](./docs/routing.md#authentication--guards).
|
|
83
|
+
- `AppConfig.initialLoad` / `AppConfig.useIntercept` — forwarded to `Router` alongside `enter` (previously also unreachable; `initialLoad: false` is needed for guard unit tests that drive navigation explicitly).
|
|
84
|
+
- Vitest + happy-dom test infrastructure (`npm test`) — first automated test suite for this package, covering `AppConfig.enter` redirect/cancel behavior.
|
|
85
|
+
|
|
86
|
+
### Changed
|
|
87
|
+
- `package.json` `scripts`: `"test"` now runs `vitest run` (was `vite`, which only started the dev server). The old behavior is available as `"start"`.
|
|
88
|
+
|
|
3
89
|
## [0.4.0] - 2026-07-02
|
|
4
90
|
|
|
5
91
|
### Added
|
package/README.md
CHANGED
|
@@ -77,6 +77,15 @@ app.theme.set('dark');
|
|
|
77
77
|
app.theme.isInitialized; // boolean
|
|
78
78
|
```
|
|
79
79
|
|
|
80
|
+
Rebranding the shell is one declaration — the sidebar's active menu follows it:
|
|
81
|
+
|
|
82
|
+
```css
|
|
83
|
+
:root { --u-primary-color: #7B1FA2; }
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Shell surfaces can also be tuned on their own (`--app-sidebar-bg`, `--app-sidebar-width`, …).
|
|
87
|
+
See [theme.md](./docs/theme.md).
|
|
88
|
+
|
|
80
89
|
### Notifications
|
|
81
90
|
|
|
82
91
|
```typescript
|
|
@@ -109,14 +118,69 @@ import { translate } from 'lit-i18n';
|
|
|
109
118
|
html`<p>${translate('common::greeting')}</p>`;
|
|
110
119
|
```
|
|
111
120
|
|
|
121
|
+
### 부팅 인증 게이트 (`auth`)
|
|
122
|
+
|
|
123
|
+
셸을 만들기 전에 세션을 판정한다. 소비앱이 `app.load()` 앞단에 손으로 짜던 "me 조회 → 미인증이면 로그인, 인증이면 앱 로드" 게이트를 표준화한다. 세션 조회/로그인 HTTP 는 `@iyulab/enterprise` 의 `createAuthClient` 가, 세션-중 401 은 `createODataService` 의 `onUnauthorized` 가 담당한다(프레임워크는 오케스트레이션만 소유).
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { createAuthClient, setPermissions } from '@iyulab/enterprise';
|
|
127
|
+
|
|
128
|
+
const auth = createAuthClient<User, Cred>({ meUrl: '/api/auth/me', loginUrl: '/api/auth/login', logoutUrl: '/api/auth/logout' });
|
|
129
|
+
|
|
130
|
+
await app.load({
|
|
131
|
+
layout: { type: 'sidebar', /* ... */ },
|
|
132
|
+
auth: {
|
|
133
|
+
me: () => auth.fetchMe(), // null → 미인증 → renderLogin
|
|
134
|
+
renderLogin: ({ root, onSuccess }) => renderLoginPage(root, auth, onSuccess),
|
|
135
|
+
onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
136
|
+
},
|
|
137
|
+
routes: [ /* ... */ ],
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
app.user; // 인증된 현재 사용자(미인증/미사용 시 undefined)
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `me()` 가 값을 반환하면 셸 로드, `null`/`undefined` 면 `renderLogin({ root, onSuccess })`.
|
|
144
|
+
- 로그인 성공 시 `onSuccess()` 를 호출하면 앱이 재로드되어 셸이 나타나고 로그인 UI 는 정리된다.
|
|
145
|
+
- `auth` 미지정 시 완전히 하위호환(게이트 없이 기존대로 로드).
|
|
146
|
+
|
|
147
|
+
### 권한 기반 메뉴 필터
|
|
148
|
+
|
|
149
|
+
모든 사이드바 메뉴 항목에 `requirePermission`/`requireAnyPermission` 를 달고, 레이아웃에 `hasPermission` 판정 함수를 주면 권한 없는 항목이 숨겨진다. 항목이 모두 걸러진 section/group 은 통째로 숨는다. 소비앱이 손으로 짜던 `filterMenu` 를 대체한다.
|
|
150
|
+
|
|
151
|
+
```typescript
|
|
152
|
+
import { hasPermission } from '@iyulab/enterprise';
|
|
153
|
+
|
|
154
|
+
await app.load({
|
|
155
|
+
layout: {
|
|
156
|
+
type: 'sidebar',
|
|
157
|
+
hasPermission, // enterprise 권한 store 판정
|
|
158
|
+
main: [
|
|
159
|
+
{ type: 'link', icon: 'house', label: '홈', href: '/' },
|
|
160
|
+
{ type: 'link', icon: 'gear', label: '설정', href: '/settings', requirePermission: 'admin.maintenance' },
|
|
161
|
+
{
|
|
162
|
+
type: 'section', title: '주문',
|
|
163
|
+
items: [
|
|
164
|
+
{ type: 'link', label: '주문 목록', href: '/orders', requireAnyPermission: ['orders.read', 'orders.write'] },
|
|
165
|
+
],
|
|
166
|
+
},
|
|
167
|
+
],
|
|
168
|
+
},
|
|
169
|
+
auth: { /* ... */ },
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
- `hasPermission` 미지정 시 필터링하지 않는다(모든 항목 표시 — 하위호환).
|
|
174
|
+
- 순수 헬퍼 `filterSidebarItems(items, hasPermission)` 를 직접 재사용할 수도 있다.
|
|
175
|
+
|
|
112
176
|
## Documentation
|
|
113
177
|
|
|
114
178
|
| Guide | Description |
|
|
115
179
|
|-------|-------------|
|
|
116
180
|
| [getting-started.md](./docs/getting-started.md) | Bootstrap, architecture, entry point setup |
|
|
117
|
-
| [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress |
|
|
181
|
+
| [routing.md](./docs/routing.md) | Route config, URL params, async routes, progress, auth guards |
|
|
118
182
|
| [layout.md](./docs/layout.md) | Sidebar layout, all menu item types, responsive behaviour |
|
|
119
|
-
| [theme.md](./docs/theme.md) | Theme init, runtime switching,
|
|
183
|
+
| [theme.md](./docs/theme.md) | Theme init, runtime switching, token layers, shell surface tokens |
|
|
120
184
|
| [notifications.md](./docs/notifications.md) | Toast methods and options |
|
|
121
185
|
| [i18n.md](./docs/i18n.md) | i18next setup, plugins, lit-i18n usage |
|
|
122
186
|
| [configuration.md](./docs/configuration.md) | Full TypeScript interface reference |
|
package/dist/App.d.ts
CHANGED
|
@@ -12,6 +12,8 @@ declare class App {
|
|
|
12
12
|
private _layout?;
|
|
13
13
|
private _router?;
|
|
14
14
|
private _screen?;
|
|
15
|
+
private _user?;
|
|
16
|
+
private _loginTeardown?;
|
|
15
17
|
private constructor();
|
|
16
18
|
/** 싱글톤 인스턴스 반환 */
|
|
17
19
|
static get instance(): App;
|
|
@@ -21,6 +23,8 @@ declare class App {
|
|
|
21
23
|
get router(): Router | undefined;
|
|
22
24
|
/** 화면 크기 반환 */
|
|
23
25
|
get screen(): ScreenSize | undefined;
|
|
26
|
+
/** 인증 게이트(`auth`) 사용 시 인증된 현재 사용자. 미인증/미사용이면 undefined. */
|
|
27
|
+
get user(): unknown;
|
|
24
28
|
/** 스타일 테마 관리 유틸리티 객체 반환 */
|
|
25
29
|
get theme(): typeof Theme;
|
|
26
30
|
/** 다국어 로컬라이저(i18next) 반환 */
|
package/dist/App.js
CHANGED
|
@@ -18,6 +18,9 @@ var o = class o {
|
|
|
18
18
|
get screen() {
|
|
19
19
|
return this._screen?.get();
|
|
20
20
|
}
|
|
21
|
+
get user() {
|
|
22
|
+
return this._user;
|
|
23
|
+
}
|
|
21
24
|
get theme() {
|
|
22
25
|
return i;
|
|
23
26
|
}
|
|
@@ -25,7 +28,21 @@ var o = class o {
|
|
|
25
28
|
return t;
|
|
26
29
|
}
|
|
27
30
|
async load(a) {
|
|
28
|
-
if (this.unload(), this._config = a,
|
|
31
|
+
if (this.unload(), this._config = a, a.auth) {
|
|
32
|
+
let e = await a.auth.me();
|
|
33
|
+
if (e == null) {
|
|
34
|
+
let e = a.root || document.body, t = a.auth.renderLogin({
|
|
35
|
+
root: e,
|
|
36
|
+
onSuccess: () => {
|
|
37
|
+
this.load(a);
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
t && (this._loginTeardown = t);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
this._user = e, await a.auth.onAuthenticated?.(e);
|
|
44
|
+
}
|
|
45
|
+
if (await i.init(a.theme), a.iconBasepath && r(a.iconBasepath), a.i18n) {
|
|
29
46
|
for (let e of a.i18n.plugins || []) t.use(e);
|
|
30
47
|
await t.init(a.i18n);
|
|
31
48
|
}
|
|
@@ -39,11 +56,14 @@ var o = class o {
|
|
|
39
56
|
root: this._layout,
|
|
40
57
|
basepath: a.basepath,
|
|
41
58
|
routes: a.routes,
|
|
42
|
-
fallback: a.fallback
|
|
59
|
+
fallback: a.fallback,
|
|
60
|
+
enter: a.enter,
|
|
61
|
+
initialLoad: a.initialLoad,
|
|
62
|
+
useIntercept: a.useIntercept
|
|
43
63
|
});
|
|
44
64
|
}
|
|
45
65
|
unload() {
|
|
46
|
-
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;
|
|
66
|
+
this._loginTeardown &&= (this._loginTeardown(), 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;
|
|
47
67
|
}
|
|
48
68
|
navigate(e) {
|
|
49
69
|
this._router?.go(e);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.142.0/helpers/esm/decorate.js
|
|
2
2
|
function e(e, t, n, r) {
|
|
3
3
|
var i = arguments.length, a = i < 3 ? t : r === null ? r = Object.getOwnPropertyDescriptor(t, n) : r, o;
|
|
4
4
|
if (typeof Reflect == "object" && typeof Reflect.decorate == "function") a = Reflect.decorate(e, t, n, r);
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
//#region \0@oxc-project+runtime@0.
|
|
1
|
+
//#region \0@oxc-project+runtime@0.142.0/helpers/esm/decorateMetadata.js
|
|
2
2
|
function e(e, t) {
|
|
3
3
|
if (typeof Reflect == "object" && typeof Reflect.metadata == "function") return Reflect.metadata(e, t);
|
|
4
4
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
3
4
|
/** 버튼 항목 부분 */
|
|
4
5
|
type ElementParts = 'host' | 'base' | 'icon' | 'label';
|
|
5
6
|
/** 버튼 항목 구성 */
|
|
6
|
-
export interface SidebarButtonConfig {
|
|
7
|
+
export interface SidebarButtonConfig extends SidebarPermissionGuard {
|
|
7
8
|
type: 'button';
|
|
8
9
|
icon?: string;
|
|
9
10
|
label?: string | DirectiveResult;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import e from "../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import t from "../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
3
3
|
import { StyledElement as n } from "../internals/StyledElement.js";
|
|
4
4
|
import { styles as r } from "./SidebarButton.styles.js";
|
|
5
5
|
import { html as i } from "lit";
|
|
@@ -4,7 +4,7 @@ var t = e`
|
|
|
4
4
|
:host {
|
|
5
5
|
display: block;
|
|
6
6
|
width: 100%;
|
|
7
|
-
color: var(--u-txt-color);
|
|
7
|
+
color: var(--app-sidebar-fg, var(--u-txt-color, #212121));
|
|
8
8
|
background-color: transparent;
|
|
9
9
|
border: none;
|
|
10
10
|
border-radius: 8px;
|
|
@@ -12,11 +12,11 @@ var t = e`
|
|
|
12
12
|
cursor: pointer;
|
|
13
13
|
}
|
|
14
14
|
:host(:hover) {
|
|
15
|
-
color: var(--u-txt-color-hover);
|
|
16
|
-
background-color: var(--u-bg-color-hover);
|
|
15
|
+
color: var(--u-txt-color-hover, #1E88E5);
|
|
16
|
+
background-color: var(--u-bg-color-hover, #F5F5F5);
|
|
17
17
|
}
|
|
18
18
|
:host(:active) {
|
|
19
|
-
background-color: var(--u-bg-color-active);
|
|
19
|
+
background-color: var(--u-bg-color-active, #EEEEEE);
|
|
20
20
|
}
|
|
21
21
|
|
|
22
22
|
button {
|
|
@@ -35,7 +35,7 @@ var t = e`
|
|
|
35
35
|
padding: 8px;
|
|
36
36
|
}
|
|
37
37
|
button:focus-visible {
|
|
38
|
-
outline: 2px solid #
|
|
38
|
+
outline: 2px solid var(--u-primary-color-weak, #2196F3);
|
|
39
39
|
outline-offset: 2px;
|
|
40
40
|
}
|
|
41
41
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
3
|
import { SidebarLinkConfig } from './SidebarLink.js';
|
|
4
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
4
5
|
type ElementParts = 'host' | 'header' | 'icon' | 'label' | 'caret' | 'items';
|
|
5
6
|
/** 그룹: 하위 링크들 묶음 */
|
|
6
|
-
export interface SidebarGroupConfig {
|
|
7
|
+
export interface SidebarGroupConfig extends SidebarPermissionGuard {
|
|
7
8
|
type: 'group';
|
|
8
9
|
/** 기본 접힘 상태 */
|
|
9
10
|
collapsed?: boolean;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import e from "../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import t from "../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
3
3
|
import { StyledElement as n } from "../internals/StyledElement.js";
|
|
4
4
|
import { SidebarLink as r } from "./SidebarLink.js";
|
|
5
5
|
import { styles as i } from "./SidebarGroup.styles.js";
|
|
@@ -2,9 +2,12 @@ import { css as e } from "lit";
|
|
|
2
2
|
//#region src/components/SidebarGroup.styles.ts
|
|
3
3
|
var t = e`
|
|
4
4
|
:host {
|
|
5
|
+
/* SidebarLink 과 같은 원천 — 그룹 강조는 활성 링크와 같은 색 계열이어야 한다 */
|
|
6
|
+
--group-accent: var(--app-sidebar-active-bg, var(--u-primary-color, #1E88E5));
|
|
7
|
+
|
|
5
8
|
display: flex;
|
|
6
9
|
flex-direction: column;
|
|
7
|
-
color: var(--u-
|
|
10
|
+
color: var(--app-sidebar-fg, var(--u-txt-color, #212121));
|
|
8
11
|
}
|
|
9
12
|
|
|
10
13
|
button {
|
|
@@ -28,17 +31,19 @@ var t = e`
|
|
|
28
31
|
padding: 8px;
|
|
29
32
|
}
|
|
30
33
|
button[selected] {
|
|
31
|
-
|
|
34
|
+
/* 패널 위 텍스트라 강조색을 그대로 쓰면 대비가 부족하다(blue-600 = 3.68:1).
|
|
35
|
+
활성 배경과 같은 파생식으로 한 단 어둡게 해 AA 를 넘긴다(4.85:1). */
|
|
36
|
+
color: color-mix(in srgb, var(--group-accent) 85%, black);
|
|
32
37
|
}
|
|
33
38
|
button:hover {
|
|
34
|
-
color: var(--u-txt-color-hover);
|
|
35
|
-
background-color: var(--u-bg-color-hover);
|
|
39
|
+
color: var(--u-txt-color-hover, #1E88E5);
|
|
40
|
+
background-color: var(--u-bg-color-hover, #F5F5F5);
|
|
36
41
|
}
|
|
37
42
|
button:active {
|
|
38
|
-
background-color: var(--u-bg-color-active);
|
|
43
|
+
background-color: var(--u-bg-color-active, #EEEEEE);
|
|
39
44
|
}
|
|
40
45
|
button:focus-visible {
|
|
41
|
-
outline: 2px solid #
|
|
46
|
+
outline: 2px solid var(--u-primary-color-weak, #2196F3);
|
|
42
47
|
outline-offset: 2px;
|
|
43
48
|
}
|
|
44
49
|
|
|
@@ -73,7 +78,7 @@ var t = e`
|
|
|
73
78
|
gap: 4px;
|
|
74
79
|
margin-top: 4px;
|
|
75
80
|
margin-left: 32px;
|
|
76
|
-
border-left: 2px solid var(--u-border-color-weak);
|
|
81
|
+
border-left: 2px solid var(--u-border-color-weak, #EEEEEE);
|
|
77
82
|
padding-left: 8px;
|
|
78
83
|
overflow: hidden;
|
|
79
84
|
transition: all 0.3s ease;
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { DirectiveResult } from 'lit/directive.js';
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
3
4
|
type ElementParts = 'host' | 'base' | 'icon' | 'label';
|
|
4
5
|
/** 링크 항목 디폴트 타입 */
|
|
5
|
-
export interface SidebarLinkConfig {
|
|
6
|
+
export interface SidebarLinkConfig extends SidebarPermissionGuard {
|
|
6
7
|
type: 'link';
|
|
7
8
|
icon?: string;
|
|
8
9
|
label: string | DirectiveResult;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import e from "../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import t from "../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
3
3
|
import { StyledElement as n } from "../internals/StyledElement.js";
|
|
4
4
|
import { styles as r } from "./SidebarLink.styles.js";
|
|
5
5
|
import { html as i } from "lit";
|
|
@@ -2,26 +2,32 @@ import { css as e } from "lit";
|
|
|
2
2
|
//#region src/components/SidebarLink.styles.ts
|
|
3
3
|
var t = e`
|
|
4
4
|
:host {
|
|
5
|
+
/* 활성 배경의 단일 원천. 소비자는 셸 계약 토큰(--app-sidebar-active-bg) 또는
|
|
6
|
+
역할 토큰(--u-primary-color) 어느 쪽을 덮어도 hover 파생까지 함께 따라온다.
|
|
7
|
+
팔레트(--u-blue-600)를 직접 읽으면 브랜드를 바꾸려는 소비자가 "진짜 파랑"이
|
|
8
|
+
필요한 배지·차트까지 함께 오염시키는 길밖에 없다. */
|
|
9
|
+
--link-active-bg: var(--app-sidebar-active-bg, var(--u-primary-color, #1E88E5));
|
|
10
|
+
|
|
5
11
|
display: block;
|
|
6
|
-
color: var(--u-
|
|
12
|
+
color: var(--app-sidebar-fg, var(--u-txt-color, #212121));
|
|
7
13
|
background-color: transparent;
|
|
8
14
|
border-radius: 8px;
|
|
9
15
|
transition: all 0.2s ease;
|
|
10
16
|
cursor: pointer;
|
|
11
17
|
}
|
|
12
18
|
:host(:hover) {
|
|
13
|
-
color: var(--u-txt-color-hover);
|
|
14
|
-
background-color: var(--u-bg-color-hover);
|
|
19
|
+
color: var(--u-txt-color-hover, #1E88E5);
|
|
20
|
+
background-color: var(--u-bg-color-hover, #F5F5F5);
|
|
15
21
|
}
|
|
16
22
|
:host([selected]) {
|
|
17
|
-
color: var(--u-txt-color-inverse);
|
|
18
|
-
background-color: var(--
|
|
19
|
-
box-shadow: 0 1px 3px var(--u-shadow-color-weak);
|
|
23
|
+
color: var(--app-sidebar-active-fg, var(--u-txt-color-inverse, #FFFFFF));
|
|
24
|
+
background-color: var(--link-active-bg);
|
|
25
|
+
box-shadow: 0 1px 3px var(--u-shadow-color-weak, rgba(0, 0, 0, 0.08));
|
|
20
26
|
}
|
|
21
27
|
:host([selected]:hover) {
|
|
22
|
-
color: var(--u-txt-color-inverse);
|
|
23
|
-
background-color: var(--
|
|
24
|
-
box-shadow: 0 2px 6px var(--u-shadow-color-normal);
|
|
28
|
+
color: var(--app-sidebar-active-fg, var(--u-txt-color-inverse, #FFFFFF));
|
|
29
|
+
background-color: color-mix(in srgb, var(--link-active-bg) 85%, black);
|
|
30
|
+
box-shadow: 0 2px 6px var(--u-shadow-color-normal, rgba(0, 0, 0, 0.12));
|
|
25
31
|
}
|
|
26
32
|
|
|
27
33
|
.container {
|
|
@@ -2,9 +2,10 @@ import { DirectiveResult } from 'lit/directive.js';
|
|
|
2
2
|
import { StyledElement, StyleMap } from '../internals/StyledElement.js';
|
|
3
3
|
import { SidebarLinkConfig } from './SidebarLink.js';
|
|
4
4
|
import { SidebarGroupConfig } from './SidebarGroup.js';
|
|
5
|
+
import { SidebarPermissionGuard } from '../layouts/SidebarPermission.js';
|
|
5
6
|
type ElementParts = 'host' | 'header' | 'title' | 'subtitle' | 'items';
|
|
6
7
|
/** 섹션 내에는 그룹 또는 링크들만 허용 */
|
|
7
|
-
export interface SidebarSectionConfig {
|
|
8
|
+
export interface SidebarSectionConfig extends SidebarPermissionGuard {
|
|
8
9
|
type: 'section';
|
|
9
10
|
title: string | DirectiveResult;
|
|
10
11
|
subTitle?: string | DirectiveResult;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import e from "../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import t from "../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
3
3
|
import { StyledElement as n } from "../internals/StyledElement.js";
|
|
4
4
|
import { styles as r } from "./SidebarSection.styles.js";
|
|
5
5
|
import { html as i } from "lit";
|
|
@@ -14,8 +14,13 @@ var t = e`
|
|
|
14
14
|
padding: 8px 12px 4px;
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
+
/* ⚠아래 두 색만 팔레트를 직접 읽는다 — 시맨틱 토큰으로 옮기면 대비가 무너지기 때문이다.
|
|
18
|
+
섹션 제목·부제는 본문(neutral-800)보다 약한 3단 위계를 이루는데, 역할 층의 보조
|
|
19
|
+
텍스트 단은 --u-txt-color-weak 하나뿐이고 그 값은 흰 배경에서 2.68:1 이라
|
|
20
|
+
WCAG AA(4.5:1)에 미달한다. 현행 두 값은 6.19:1 · 4.61:1 로 통과한다.
|
|
21
|
+
⇒ 역할 층에 AA 를 넘는 보조 텍스트 단이 생기면 그때 옮긴다. */
|
|
17
22
|
.title {
|
|
18
|
-
color: var(--u-neutral-700);
|
|
23
|
+
color: var(--u-neutral-700, #616161);
|
|
19
24
|
font-size: 12px;
|
|
20
25
|
font-weight: 700;
|
|
21
26
|
letter-spacing: 0.5px;
|
|
@@ -25,7 +30,7 @@ var t = e`
|
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
.subtitle {
|
|
28
|
-
color: var(--u-neutral-600);
|
|
33
|
+
color: var(--u-neutral-600, #757575);
|
|
29
34
|
font-size: 11px;
|
|
30
35
|
font-weight: 300;
|
|
31
36
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { app } from './App.js';
|
|
2
2
|
export type * from './types/AppConfigs';
|
|
3
3
|
export type * from './types/AppOptions';
|
|
4
|
+
export type * from './types/AuthConfig';
|
|
5
|
+
export type { SidebarPermissionGuard } from './layouts/SidebarPermission';
|
|
6
|
+
export { filterSidebarItems } from './layouts/filterSidebarItems.js';
|
|
4
7
|
export { app };
|
|
5
8
|
export default app;
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { app as e } from "./App.js";
|
|
2
|
+
import { filterSidebarItems as t } from "./layouts/filterSidebarItems.js";
|
|
2
3
|
//#region src/index.ts
|
|
3
|
-
var
|
|
4
|
+
var n = e;
|
|
4
5
|
//#endregion
|
|
5
|
-
export { e as app,
|
|
6
|
+
export { e as app, n as default, t as filterSidebarItems };
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import e from "../_virtual/_@oxc-project_runtime@0.
|
|
2
|
-
import t from "../_virtual/_@oxc-project_runtime@0.
|
|
1
|
+
import e from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
2
|
+
import t from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
3
3
|
import { property as n } from "lit/decorators.js";
|
|
4
4
|
import { UElement as r } from "@iyulab/components/dist/components/UElement.js";
|
|
5
5
|
//#region src/internals/StyledElement.ts
|
|
@@ -1,27 +1,28 @@
|
|
|
1
1
|
import { app as e } from "../App.js";
|
|
2
|
-
import t from "
|
|
3
|
-
import n from "../_virtual/_@oxc-project_runtime@0.
|
|
4
|
-
import
|
|
2
|
+
import { filterSidebarItems as t } from "./filterSidebarItems.js";
|
|
3
|
+
import n from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js";
|
|
4
|
+
import r from "../_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js";
|
|
5
|
+
import { StyledElement as i } from "../internals/StyledElement.js";
|
|
5
6
|
import "../components/SidebarSection.js";
|
|
6
7
|
import "../components/SidebarLink.js";
|
|
7
8
|
import "../components/SidebarGroup.js";
|
|
8
9
|
import "../components/SidebarButton.js";
|
|
9
|
-
import { styles as
|
|
10
|
-
import { html as
|
|
11
|
-
import { customElement as
|
|
12
|
-
import { unsafeHTML as
|
|
13
|
-
import { repeat as
|
|
10
|
+
import { styles as a } from "./SidebarLayout.styles.js";
|
|
11
|
+
import { html as o, nothing as s } from "lit";
|
|
12
|
+
import { customElement as c, property as l, query as u, state as d } from "lit/decorators.js";
|
|
13
|
+
import { unsafeHTML as f } from "lit/directives/unsafe-html.js";
|
|
14
|
+
import { repeat as p } from "lit/directives/repeat.js";
|
|
14
15
|
import "@iyulab/components/dist/components/icon/UIcon.js";
|
|
15
16
|
import "@iyulab/components/dist/components/button/UButton.js";
|
|
16
|
-
import { UProgressBar as
|
|
17
|
+
import { UProgressBar as m } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
|
|
17
18
|
//#region src/layouts/SidebarLayout.ts
|
|
18
|
-
function
|
|
19
|
+
function h(e) {
|
|
19
20
|
let t = e.tagName;
|
|
20
21
|
if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
|
|
21
22
|
let n = e.getAttribute("role");
|
|
22
23
|
return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
|
|
23
24
|
}
|
|
24
|
-
var
|
|
25
|
+
var g = class extends i {
|
|
25
26
|
constructor(...t) {
|
|
26
27
|
super(...t), this.state = "default", this.context = null, this.isMatchedLink = (e) => !this.context || !e ? !1 : (e = typeof e == "string" ? new URLPattern(e, window.location.origin) : e, e.test(this.context.path, window.location.origin)), this.handleBrandLogoClick = (t) => () => {
|
|
27
28
|
e.navigate(t ?? "");
|
|
@@ -39,12 +40,12 @@ var h = class extends r {
|
|
|
39
40
|
this.progressBarEl.removeAttribute("visible");
|
|
40
41
|
}, 300), (this.shadowRoot?.querySelector(".main"))?.focus({ preventScroll: !0 });
|
|
41
42
|
}, this.handleRouteError = (e) => {
|
|
42
|
-
this.progressBarEl.
|
|
43
|
-
this.progressBarEl.removeAttribute("visible"), this.progressBarEl.
|
|
43
|
+
this.progressBarEl.status = "error", this.progressBarEl.value = 100, setTimeout(() => {
|
|
44
|
+
this.progressBarEl.removeAttribute("visible"), this.progressBarEl.status = "default";
|
|
44
45
|
}, 300);
|
|
45
46
|
}, this._handleMainKeydown = (e) => {
|
|
46
47
|
let t = e.composedPath()[0];
|
|
47
|
-
if (t instanceof HTMLElement &&
|
|
48
|
+
if (t instanceof HTMLElement && h(t)) return;
|
|
48
49
|
let n = this.shadowRoot?.querySelector(".main");
|
|
49
50
|
if (!n) return;
|
|
50
51
|
let r = n.clientHeight;
|
|
@@ -77,7 +78,7 @@ var h = class extends r {
|
|
|
77
78
|
};
|
|
78
79
|
}
|
|
79
80
|
static {
|
|
80
|
-
this.styles = [super.styles,
|
|
81
|
+
this.styles = [super.styles, a];
|
|
81
82
|
}
|
|
82
83
|
connectedCallback() {
|
|
83
84
|
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);
|
|
@@ -89,7 +90,7 @@ var h = class extends r {
|
|
|
89
90
|
super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
|
|
90
91
|
}
|
|
91
92
|
render() {
|
|
92
|
-
return this.config ?
|
|
93
|
+
return this.config ? o`
|
|
93
94
|
<!-- Mobile Header -->
|
|
94
95
|
<div class="mobile-header" part="mobile-header" ?hidden="${!this.state.startsWith("mobile")}">
|
|
95
96
|
${this.renderLogo()}
|
|
@@ -124,12 +125,12 @@ var h = class extends r {
|
|
|
124
125
|
|
|
125
126
|
<!-- Sidebar Navigation Menu -->
|
|
126
127
|
<nav class="sidebar-main" part="sidebar-main" scrollable>
|
|
127
|
-
${
|
|
128
|
+
${p(t(this.config.main ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
128
129
|
</nav>
|
|
129
130
|
|
|
130
131
|
<!-- Sidebar Footer -->
|
|
131
132
|
<div class="sidebar-footer" part="sidebar-footer">
|
|
132
|
-
${
|
|
133
|
+
${p(t(this.config.footer ?? [], this.config.hasPermission), (e, t) => t, (e) => this.renderItem(e))}
|
|
133
134
|
</div>
|
|
134
135
|
</aside>
|
|
135
136
|
|
|
@@ -144,14 +145,15 @@ var h = class extends r {
|
|
|
144
145
|
<div class="backdrop" ?hidden="${this.state !== "modal"}"
|
|
145
146
|
@click="${this.handleBackdropClick}"
|
|
146
147
|
></div>
|
|
147
|
-
` :
|
|
148
|
+
` : s;
|
|
148
149
|
}
|
|
149
150
|
renderItem(e) {
|
|
150
|
-
if (!e) return
|
|
151
|
+
if (!e) return s;
|
|
151
152
|
if (e.type === "html") {
|
|
152
153
|
let t = e.render(this.state);
|
|
153
|
-
return typeof t == "string" ?
|
|
154
|
-
}
|
|
154
|
+
return typeof t == "string" ? f(t) : o`${t}`;
|
|
155
|
+
}
|
|
156
|
+
if (e.type === "button") return o`
|
|
155
157
|
<u-sidebar-button
|
|
156
158
|
?compact=${this.state === "slim"}
|
|
157
159
|
.icon="${e.icon}"
|
|
@@ -160,9 +162,9 @@ var h = class extends r {
|
|
|
160
162
|
@click="${e.onClick}"
|
|
161
163
|
></u-sidebar-button>
|
|
162
164
|
`;
|
|
163
|
-
|
|
165
|
+
if (e.type === "link") {
|
|
164
166
|
let t = this.isMatchedLink(e.pattern || e.href);
|
|
165
|
-
return
|
|
167
|
+
return o`
|
|
166
168
|
<u-sidebar-link
|
|
167
169
|
?compact=${this.state === "slim"}
|
|
168
170
|
?selected=${t}
|
|
@@ -173,18 +175,19 @@ var h = class extends r {
|
|
|
173
175
|
.styles="${e.styles}"
|
|
174
176
|
></u-sidebar-link>
|
|
175
177
|
`;
|
|
176
|
-
}
|
|
178
|
+
}
|
|
179
|
+
if (e.type === "section") return o`
|
|
177
180
|
<u-sidebar-section
|
|
178
181
|
?compact=${this.state === "slim"}
|
|
179
182
|
.mainTitle="${e.title}"
|
|
180
183
|
.subTitle="${e.subTitle}"
|
|
181
184
|
.styles="${e.styles}">
|
|
182
|
-
${
|
|
185
|
+
${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
183
186
|
</u-sidebar-section>
|
|
184
187
|
`;
|
|
185
|
-
|
|
188
|
+
if (e.type === "group") {
|
|
186
189
|
let t = e.items.some((e) => this.isMatchedLink(e.pattern || e.href));
|
|
187
|
-
return
|
|
190
|
+
return o`
|
|
188
191
|
<u-sidebar-group
|
|
189
192
|
?compact=${this.state === "slim"}
|
|
190
193
|
?selected=${t}
|
|
@@ -192,14 +195,15 @@ var h = class extends r {
|
|
|
192
195
|
.icon="${e.icon}"
|
|
193
196
|
.label="${e.label}"
|
|
194
197
|
.styles="${e.styles}">
|
|
195
|
-
${
|
|
198
|
+
${p(e.items, (e, t) => t, (e) => this.renderItem(e))}
|
|
196
199
|
</u-sidebar-group>
|
|
197
200
|
`;
|
|
198
|
-
}
|
|
201
|
+
}
|
|
202
|
+
return s;
|
|
199
203
|
}
|
|
200
204
|
renderLogo() {
|
|
201
205
|
let e = this.config?.logo;
|
|
202
|
-
if (!e || typeof e == "string") return
|
|
206
|
+
if (!e || typeof e == "string") return o`
|
|
203
207
|
<u-icon class="logo"
|
|
204
208
|
.name="${e}"
|
|
205
209
|
@click=${this.handleBrandLogoClick()}
|
|
@@ -207,13 +211,13 @@ var h = class extends r {
|
|
|
207
211
|
`;
|
|
208
212
|
if (typeof e == "function") {
|
|
209
213
|
let t = e(this.state);
|
|
210
|
-
return
|
|
214
|
+
return o`
|
|
211
215
|
<span class="logo" @click=${this.handleBrandLogoClick()}>
|
|
212
|
-
${typeof t == "string" ?
|
|
216
|
+
${typeof t == "string" ? f(t) : t}
|
|
213
217
|
</span>
|
|
214
218
|
`;
|
|
215
219
|
}
|
|
216
|
-
return
|
|
220
|
+
return o`
|
|
217
221
|
<img class="logo"
|
|
218
222
|
src="${e.src}"
|
|
219
223
|
alt="${e.alt ?? ""}"
|
|
@@ -222,9 +226,9 @@ var h = class extends r {
|
|
|
222
226
|
`;
|
|
223
227
|
}
|
|
224
228
|
};
|
|
225
|
-
|
|
229
|
+
r([l({
|
|
226
230
|
type: String,
|
|
227
231
|
reflect: !0
|
|
228
|
-
}),
|
|
232
|
+
}), n("design:type", Object)], g.prototype, "state", void 0), r([l({ type: Object }), n("design:type", Object)], g.prototype, "config", void 0), r([u("u-progress-bar"), n("design:type", m === void 0 ? Object : m)], g.prototype, "progressBarEl", void 0), r([d(), n("design:type", Object)], g.prototype, "context", void 0), g = r([c("u-sidebar-layout")], g);
|
|
229
233
|
//#endregion
|
|
230
|
-
export {
|
|
234
|
+
export { g as SidebarLayout };
|
|
@@ -16,12 +16,12 @@ var t = e`
|
|
|
16
16
|
}
|
|
17
17
|
|
|
18
18
|
.logo {
|
|
19
|
-
color: var(--u-txt-color);
|
|
19
|
+
color: var(--u-txt-color, #212121);
|
|
20
20
|
font-size: 24px;
|
|
21
21
|
cursor: pointer;
|
|
22
22
|
}
|
|
23
23
|
.logo:hover {
|
|
24
|
-
color: var(--u-txt-color-hover);
|
|
24
|
+
color: var(--u-txt-color-hover, #1E88E5);
|
|
25
25
|
}
|
|
26
26
|
img.logo {
|
|
27
27
|
height: 24px;
|
|
@@ -31,7 +31,7 @@ var t = e`
|
|
|
31
31
|
|
|
32
32
|
.title {
|
|
33
33
|
flex: 1;
|
|
34
|
-
color: var(--u-txt-color);
|
|
34
|
+
color: var(--u-txt-color, #212121);
|
|
35
35
|
font-size: 18px;
|
|
36
36
|
line-height: 24px;
|
|
37
37
|
font-weight: 700;
|
|
@@ -48,7 +48,7 @@ var t = e`
|
|
|
48
48
|
background: transparent;
|
|
49
49
|
}
|
|
50
50
|
.toggler:hover {
|
|
51
|
-
color: var(--u-txt-color-hover);
|
|
51
|
+
color: var(--u-txt-color-hover, #1E88E5);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
/* Sidebar Container */
|
|
@@ -60,16 +60,17 @@ var t = e`
|
|
|
60
60
|
justify-content: space-between;
|
|
61
61
|
gap: 8px;
|
|
62
62
|
height: 100%;
|
|
63
|
-
background: var(--u-panel-bg-color);
|
|
64
|
-
|
|
63
|
+
background: var(--app-sidebar-bg, var(--u-panel-bg-color, #FFFFFF));
|
|
64
|
+
color: var(--app-sidebar-fg, var(--u-txt-color, #212121));
|
|
65
|
+
border-right: 1px solid var(--u-border-color, #E0E0E0);
|
|
65
66
|
transition: all 0.3s ease;
|
|
66
67
|
}
|
|
67
68
|
/* Sidebar states */
|
|
68
69
|
.sidebar[state="default"] {
|
|
69
|
-
width: 260px;
|
|
70
|
+
width: var(--app-sidebar-width, 260px);
|
|
70
71
|
}
|
|
71
72
|
.sidebar[state="slim"] {
|
|
72
|
-
width: 64px;
|
|
73
|
+
width: var(--app-sidebar-width-slim, 64px);
|
|
73
74
|
}
|
|
74
75
|
.sidebar[state="slim"] .sidebar-header {
|
|
75
76
|
flex-direction: column;
|
|
@@ -77,7 +78,7 @@ var t = e`
|
|
|
77
78
|
padding: 16px;
|
|
78
79
|
}
|
|
79
80
|
.sidebar[state="modal"] {
|
|
80
|
-
width: 260px;
|
|
81
|
+
width: var(--app-sidebar-width, 260px);
|
|
81
82
|
position: absolute;
|
|
82
83
|
top: 0;
|
|
83
84
|
left: 0;
|
|
@@ -107,8 +108,9 @@ var t = e`
|
|
|
107
108
|
justify-content: space-between;
|
|
108
109
|
gap: 12px;
|
|
109
110
|
padding: 16px;
|
|
110
|
-
background: var(--u-panel-bg-color);
|
|
111
|
-
|
|
111
|
+
background: var(--app-header-bg, var(--u-panel-bg-color, #FFFFFF));
|
|
112
|
+
color: var(--app-header-fg, var(--u-txt-color, #212121));
|
|
113
|
+
border-bottom: 1px solid var(--u-border-color-weak, #EEEEEE);
|
|
112
114
|
user-select: none;
|
|
113
115
|
}
|
|
114
116
|
|
|
@@ -120,7 +122,7 @@ var t = e`
|
|
|
120
122
|
justify-content: space-between;
|
|
121
123
|
gap: 12px;
|
|
122
124
|
padding: 16px;
|
|
123
|
-
border-bottom: 1px solid var(--u-border-color-weak);
|
|
125
|
+
border-bottom: 1px solid var(--u-border-color-weak, #EEEEEE);
|
|
124
126
|
user-select: none;
|
|
125
127
|
}
|
|
126
128
|
|
|
@@ -141,7 +143,7 @@ var t = e`
|
|
|
141
143
|
flex-direction: column;
|
|
142
144
|
gap: 4px;
|
|
143
145
|
padding: 8px;
|
|
144
|
-
border-top: 1px solid var(--u-border-color-weak);
|
|
146
|
+
border-top: 1px solid var(--u-border-color-weak, #EEEEEE);
|
|
145
147
|
}
|
|
146
148
|
.sidebar-footer:empty {
|
|
147
149
|
display: none;
|
|
@@ -151,14 +153,14 @@ var t = e`
|
|
|
151
153
|
.main {
|
|
152
154
|
position: relative;
|
|
153
155
|
flex: 1;
|
|
154
|
-
background: var(--u-bg-color);
|
|
156
|
+
background: var(--u-bg-color, #FFFFFF);
|
|
155
157
|
overflow: auto;
|
|
156
158
|
outline: none;
|
|
157
159
|
}
|
|
158
160
|
|
|
159
161
|
.main u-progress-bar {
|
|
160
162
|
--progress-bar-height: 4px;
|
|
161
|
-
--
|
|
163
|
+
--progress-bar-track-color: transparent;
|
|
162
164
|
|
|
163
165
|
position: absolute;
|
|
164
166
|
z-index: 100;
|
|
@@ -174,9 +176,6 @@ var t = e`
|
|
|
174
176
|
opacity: 1;
|
|
175
177
|
transform: translateY(0);
|
|
176
178
|
}
|
|
177
|
-
.main u-progress-bar[error] {
|
|
178
|
-
--progress-bar-color: var(--u-red-500);
|
|
179
|
-
}
|
|
180
179
|
|
|
181
180
|
/* Backdrop for modal mode */
|
|
182
181
|
.backdrop {
|
|
@@ -186,7 +185,7 @@ var t = e`
|
|
|
186
185
|
left: 0;
|
|
187
186
|
right: 0;
|
|
188
187
|
bottom: 0;
|
|
189
|
-
background: var(--u-overlay-bg-color);
|
|
188
|
+
background: var(--u-overlay-bg-color, rgba(0, 0, 0, 0.5));
|
|
190
189
|
}
|
|
191
190
|
`;
|
|
192
191
|
//#endregion
|
|
@@ -4,12 +4,14 @@ import { SidebarLinkConfig } from '../components/SidebarLink';
|
|
|
4
4
|
import { SidebarSectionConfig } from '../components/SidebarSection';
|
|
5
5
|
import { SidebarGroupConfig } from '../components/SidebarGroup';
|
|
6
6
|
import { SidebarButtonConfig } from '../components/SidebarButton';
|
|
7
|
+
import { SidebarPermissionGuard } from './SidebarPermission';
|
|
8
|
+
export type { SidebarPermissionGuard } from './SidebarPermission';
|
|
7
9
|
/** 사이드바 레이아웃 컴포넌트의 요소(part) 타입 */
|
|
8
10
|
export type SidebarParts = 'host' | 'mobile-header' | 'sidebar' | 'sidebar-header' | 'sidebar-main' | 'sidebar-footer' | 'main' | 'progress';
|
|
9
11
|
/** 사이드바 상태 타입 */
|
|
10
12
|
export type SidebarState = 'default' | 'slim' | 'modal' | 'mobile' | 'mobile-open';
|
|
11
13
|
/** 사이드바 안에 HTML 또는 엘리먼트를 직접 렌더링하는 설정 */
|
|
12
|
-
export interface SidebarHtmlConfig {
|
|
14
|
+
export interface SidebarHtmlConfig extends SidebarPermissionGuard {
|
|
13
15
|
type: 'html';
|
|
14
16
|
render: (state: SidebarState) => TemplateResult<1> | HTMLElement | string;
|
|
15
17
|
}
|
|
@@ -36,6 +38,12 @@ export interface SidebarLayoutConfig {
|
|
|
36
38
|
main?: SidebarItem[];
|
|
37
39
|
/** 하단(footer)에 고정해서 렌더할 항목들 */
|
|
38
40
|
footer?: SidebarItem[];
|
|
41
|
+
/**
|
|
42
|
+
* 메뉴 항목 권한 필터 판정. 지정하면 `requirePermission`/`requireAnyPermission` 를 만족하지
|
|
43
|
+
* 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다.
|
|
44
|
+
* 미지정 시 필터링하지 않는다(모든 항목 표시). 보통 `@iyulab/enterprise` 의 `hasPermission` 을 넘긴다.
|
|
45
|
+
*/
|
|
46
|
+
hasPermission?: (code: string) => boolean;
|
|
39
47
|
/** 사이드바 스타일 맵 */
|
|
40
48
|
styles?: StyleMap<SidebarParts>;
|
|
41
49
|
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 사이드바 메뉴 항목의 권한 가드. 모든 메뉴 config(link/section/group/button/html)가 확장한다.
|
|
3
|
+
*
|
|
4
|
+
* 판정 자체는 `SidebarLayoutConfig.hasPermission` 으로 **주입**한다 — 라이브러리는 권한 코드를
|
|
5
|
+
* 불투명 문자열로만 다루고 의미(도메인)는 앱이 소유한다. `@iyulab/enterprise` 의 `hasPermission`
|
|
6
|
+
* 을 그대로 넘겨 쓰는 것이 일반적이다.
|
|
7
|
+
*
|
|
8
|
+
* 의존성 없는 leaf 모듈 — 각 메뉴 config 파일이 순환 없이 import 한다.
|
|
9
|
+
*/
|
|
10
|
+
export interface SidebarPermissionGuard {
|
|
11
|
+
/** 이 권한 코드를 보유해야 항목이 표시된다. */
|
|
12
|
+
requirePermission?: string;
|
|
13
|
+
/** 이 코드 중 하나라도 보유하면 표시된다(빈 배열은 제약 없음). */
|
|
14
|
+
requireAnyPermission?: string[];
|
|
15
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { SidebarItem } from './SidebarLayout.types';
|
|
2
|
+
/**
|
|
3
|
+
* 권한에 따라 사이드바 메뉴 트리를 필터링한다. 소비앱이 손으로 짜던 `filterMenu` 를 표준화.
|
|
4
|
+
*
|
|
5
|
+
* - 각 항목의 `requirePermission`/`requireAnyPermission` 를 `hasPermission` 으로 검사해 탈락 항목 제거.
|
|
6
|
+
* - `section`/`group` 은 자식(`items`)을 재귀 필터하고, **자식이 모두 걸러지면 컨테이너째 제거**한다.
|
|
7
|
+
* - `hasPermission` 이 없으면(undefined) 원본을 그대로 반환한다(필터링 없음).
|
|
8
|
+
*
|
|
9
|
+
* 원본 배열/항목을 변형하지 않고 새 배열/얕은 복제본을 반환한다(순수).
|
|
10
|
+
*/
|
|
11
|
+
export declare function filterSidebarItems(items: readonly SidebarItem[], hasPermission?: (code: string) => boolean): SidebarItem[];
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
//#region src/layouts/filterSidebarItems.ts
|
|
2
|
+
function e(e, t) {
|
|
3
|
+
return !(e.requirePermission && !t(e.requirePermission) || e.requireAnyPermission && e.requireAnyPermission.length > 0 && !e.requireAnyPermission.some((e) => t(e)));
|
|
4
|
+
}
|
|
5
|
+
function t(n, r) {
|
|
6
|
+
if (!r) return n;
|
|
7
|
+
let i = r, a = [];
|
|
8
|
+
for (let r of n) if (r && e(r, i)) if (r.type === "section" || r.type === "group") {
|
|
9
|
+
let e = t(r.items, i);
|
|
10
|
+
if (e.length === 0) continue;
|
|
11
|
+
a.push({
|
|
12
|
+
...r,
|
|
13
|
+
items: e
|
|
14
|
+
});
|
|
15
|
+
} else a.push(r);
|
|
16
|
+
return a;
|
|
17
|
+
}
|
|
18
|
+
//#endregion
|
|
19
|
+
export { t as filterSidebarItems };
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import { InitOptions, Module, Newable, NewableModule } from 'i18next';
|
|
2
|
-
import { RouteConfig, FallbackRouteConfig } from '@iyulab/router';
|
|
2
|
+
import { RouteConfig, FallbackRouteConfig, RouteContext } from '@iyulab/router';
|
|
3
3
|
import { ThemeInitOptions } from '@iyulab/components/dist/utilities/Theme.js';
|
|
4
4
|
import { SidebarLayoutConfig } from '../layouts/SidebarLayout.types';
|
|
5
|
+
import { AuthGateConfig } from './AuthConfig';
|
|
5
6
|
/**
|
|
6
7
|
* i18next의 초기화 옵션과 플러그인 배열을 포함한 다국어 설정
|
|
7
8
|
*/
|
|
@@ -45,6 +46,33 @@ export interface AppConfig {
|
|
|
45
46
|
* 라우팅 실패 시 대체 컨텐츠 설정
|
|
46
47
|
*/
|
|
47
48
|
fallback?: FallbackRouteConfig;
|
|
49
|
+
/**
|
|
50
|
+
* 모든 라우트 전환 전에 호출되는 전역 인증/권한 가드입니다.
|
|
51
|
+
* - `string` 반환: 해당 경로로 redirect
|
|
52
|
+
* - `false` 반환: 네비게이션 취소(403)
|
|
53
|
+
* - `true`/무반환: 통과
|
|
54
|
+
* @example
|
|
55
|
+
* ```typescript
|
|
56
|
+
* app.load({
|
|
57
|
+
* enter: (ctx) => isAuthenticated() || `/login?returnTo=${encodeURIComponent(ctx.pathname)}`,
|
|
58
|
+
* routes: [
|
|
59
|
+
* { path: '/login', render: () => html`<login-page></login-page>` },
|
|
60
|
+
* // ...
|
|
61
|
+
* ],
|
|
62
|
+
* });
|
|
63
|
+
* ```
|
|
64
|
+
*/
|
|
65
|
+
enter?: (ctx: RouteContext) => Promise<string | boolean> | string | boolean;
|
|
66
|
+
/**
|
|
67
|
+
* 초기 로드 시 현재 URL로 라우팅을 자동으로 수행할지 여부를 설정합니다.
|
|
68
|
+
* @default true
|
|
69
|
+
*/
|
|
70
|
+
initialLoad?: boolean;
|
|
71
|
+
/**
|
|
72
|
+
* `a` 태그 클릭 시 클라이언트 라우팅을 수행할지 여부를 설정합니다.
|
|
73
|
+
* @default true
|
|
74
|
+
*/
|
|
75
|
+
useIntercept?: boolean;
|
|
48
76
|
/**
|
|
49
77
|
* 애플리케이션이 렌더링될 루트 HTML 요소
|
|
50
78
|
* @default document.body
|
|
@@ -65,4 +93,22 @@ export interface AppConfig {
|
|
|
65
93
|
* @see 설정에 대한 자세한 내용은 {@link https://www.i18next.com/overview/configuration-options} 참조하십시오.
|
|
66
94
|
*/
|
|
67
95
|
i18n?: I18nInitOptions;
|
|
96
|
+
/**
|
|
97
|
+
* 부팅 인증 게이트(선택). 지정하면 앱 셸을 만들기 전에 `me()` 로 세션을 판정하여
|
|
98
|
+
* 인증 시 셸 로드, 미인증 시 `renderLogin` 으로 로그인 UI 를 띄운다.
|
|
99
|
+
* @see AuthGateConfig
|
|
100
|
+
* @example
|
|
101
|
+
* ```typescript
|
|
102
|
+
* app.load({
|
|
103
|
+
* layout: { type: 'sidebar', ... },
|
|
104
|
+
* auth: {
|
|
105
|
+
* me: () => authClient.fetchMe(), // null → 미인증
|
|
106
|
+
* renderLogin: ({ root, onSuccess }) => renderLoginPage(root, onSuccess),
|
|
107
|
+
* onAuthenticated: (user) => setPermissions((user as User).Permissions),
|
|
108
|
+
* },
|
|
109
|
+
* routes: [ ... ],
|
|
110
|
+
* });
|
|
111
|
+
* ```
|
|
112
|
+
*/
|
|
113
|
+
auth?: AuthGateConfig;
|
|
68
114
|
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 부팅 인증 게이트 설정.
|
|
3
|
+
*
|
|
4
|
+
* `app.load({ auth })` 에 넘기면, 앱 셸(레이아웃·라우터)을 만들기 전에 세션을 판정한다:
|
|
5
|
+
* `me()` 가 값을 반환하면 인증된 것으로 보고 셸을 로드하고, `null`/`undefined` 면
|
|
6
|
+
* 미인증으로 보고 `renderLogin` 으로 로그인 UI 를 띄운다. 로그인 성공 시 `onSuccess()` 를
|
|
7
|
+
* 호출하면 앱이 (재)로드되어 셸이 나타난다.
|
|
8
|
+
*
|
|
9
|
+
* 프레임워크는 인증의 **오케스트레이션**(판정 → 분기 → 재로드)만 소유한다. 세션 조회/로그인
|
|
10
|
+
* 자체(HTTP)와 사용자·권한 형태, 세션-중 401 처리는 앱/`@iyulab/enterprise` 가 소유한다.
|
|
11
|
+
*/
|
|
12
|
+
export interface AuthGateContext {
|
|
13
|
+
/** 로그인 UI 를 그릴 루트 요소(= `app.load` 의 `root`, 기본 `document.body`). */
|
|
14
|
+
root: Element;
|
|
15
|
+
/** 로그인 성공 시 호출한다. 앱이 (재)로드되어 셸을 띄운다(이때 `me()` 는 사용자를 반환해야 한다). */
|
|
16
|
+
onSuccess: () => void;
|
|
17
|
+
}
|
|
18
|
+
export interface AuthGateConfig {
|
|
19
|
+
/**
|
|
20
|
+
* 현재 세션 조회. 값을 반환하면 인증, `null`/`undefined` 면 미인증으로 본다.
|
|
21
|
+
* 동기/비동기 모두 허용한다.
|
|
22
|
+
*/
|
|
23
|
+
me: () => Promise<unknown | null | undefined> | unknown | null | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* 미인증 시 `context.root` 에 로그인 UI 를 그린다. 성공하면 `context.onSuccess()` 를 호출해야 한다.
|
|
26
|
+
* 정리 함수를 반환하면 앱 로드/`unload` 시 호출되어 로그인 UI 를 제거한다.
|
|
27
|
+
*/
|
|
28
|
+
renderLogin: (context: AuthGateContext) => (() => void) | void;
|
|
29
|
+
/**
|
|
30
|
+
* 인증 성공 후, 앱 셸을 만들기 직전에 호출된다(선택).
|
|
31
|
+
* 사용자별 라우트/메뉴 필터 등 셸 구성 전 처리를 여기서 한다.
|
|
32
|
+
*/
|
|
33
|
+
onAuthenticated?: (user: unknown) => void | Promise<void>;
|
|
34
|
+
}
|
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.8.0",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"iyulab",
|
|
7
7
|
"web-framework",
|
|
@@ -34,19 +34,25 @@
|
|
|
34
34
|
}
|
|
35
35
|
},
|
|
36
36
|
"scripts": {
|
|
37
|
-
"test": "
|
|
38
|
-
"
|
|
37
|
+
"test": "vitest run",
|
|
38
|
+
"test:watch": "vitest",
|
|
39
|
+
"start": "vite",
|
|
40
|
+
"build": "vite build",
|
|
41
|
+
"test:browser": "vitest run --project=browser"
|
|
39
42
|
},
|
|
40
43
|
"dependencies": {
|
|
41
|
-
"@iyulab/components": "^1.
|
|
42
|
-
"@iyulab/router": "^0.10.
|
|
43
|
-
"i18next": "^26.
|
|
44
|
+
"@iyulab/components": "^1.8.0",
|
|
45
|
+
"@iyulab/router": "^0.10.2",
|
|
46
|
+
"i18next": "^26.3.6",
|
|
44
47
|
"lit": "^3.3.3"
|
|
45
48
|
},
|
|
46
49
|
"devDependencies": {
|
|
47
|
-
"@types/node": "^
|
|
50
|
+
"@types/node": "^26.1.1",
|
|
51
|
+
"@vitest/browser-playwright": "^4.1.10",
|
|
52
|
+
"happy-dom": "^20.10.6",
|
|
48
53
|
"typescript": "^5.9.3",
|
|
49
|
-
"vite": "^8.
|
|
50
|
-
"vite-plugin-dts": "^5.0.
|
|
54
|
+
"vite": "^8.1.4",
|
|
55
|
+
"vite-plugin-dts": "^5.0.3",
|
|
56
|
+
"vitest": "^4.1.10"
|
|
51
57
|
}
|
|
52
58
|
}
|