@iyulab/modern-app 0.29.0 → 0.30.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
CHANGED
|
@@ -1,5 +1,37 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.30.1] - 2026-09-28
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
- **A route screen can declare its first focus.** On every route change the shell moved focus to
|
|
8
|
+
the content area unconditionally, so a screen that focused its own input on entry — a scan field,
|
|
9
|
+
a search box — had that focus taken back, and `autofocus` in the screen did nothing. Route
|
|
10
|
+
changes now follow the overlay panel's rule: focus the screen already moved into the content
|
|
11
|
+
stays; else an `[autofocus]` element in the content (through shadow roots, after the screen's
|
|
12
|
+
first render) receives it; else the content area, as before.
|
|
13
|
+
|
|
14
|
+
## [0.30.0] - 2026-09-27
|
|
15
|
+
|
|
16
|
+
### Added
|
|
17
|
+
|
|
18
|
+
- **`noticesAriaLabel`** in the sidebar layout config. When set, the app-level notice stack
|
|
19
|
+
(`slot="notice"`) is a `role="region"` landmark with that name, so screen-reader users can jump
|
|
20
|
+
back to the notices currently shown. Without it, the only way to name the stack was to wrap the
|
|
21
|
+
notices in your own labelled section — which moved the stack's spacing and full width from each
|
|
22
|
+
notice onto the wrapper. The same shape as `mainAriaLabel`; unset keeps today's unnamed container,
|
|
23
|
+
and an empty stack is not rendered, so no empty landmark appears.
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
|
|
27
|
+
- **Escape inside the overlay now closes a drawer or dialog opened in the panel before the
|
|
28
|
+
overlay.** 0.28.0 promised that an Escape consumed inside the panel closes only that layer, but
|
|
29
|
+
that held only for layers listening before the shell: the shell listens on `window` from the
|
|
30
|
+
moment the overlay opens, so a `u-drawer` or `u-dialog` opened afterwards listened later. The
|
|
31
|
+
shell judged first, closed the overlay and marked the key consumed — and the drawer, seeing it
|
|
32
|
+
consumed, stayed open: the outer layer closed and the inner one remained. The shell now decides
|
|
33
|
+
after every layer has, and no longer marks the Escape consumed.
|
|
34
|
+
|
|
3
35
|
## [0.29.0] - 2026-09-27
|
|
4
36
|
|
|
5
37
|
### Fixed
|
|
@@ -98,6 +98,19 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
|
|
|
98
98
|
private handleRouteProgress;
|
|
99
99
|
/** 라우트 변경 완료 핸들러 */
|
|
100
100
|
private handleRouteDone;
|
|
101
|
+
/** 라우트 본문(기본 슬롯)에 배정된 요소들 */
|
|
102
|
+
private routeContent;
|
|
103
|
+
/**
|
|
104
|
+
* 라우트 완료 후 포커스 — 오버레이 패널과 같은 규칙이다.
|
|
105
|
+
* ① 화면이 이미 본문 안으로 옮겨 두었으면 건드리지 않는다
|
|
106
|
+
* ② 본문 안의 `[autofocus]`(섀도 경계 안쪽까지)
|
|
107
|
+
* ③ 그 밖에는 `.main` — 마우스 없이 키보드로 스크롤할 수 있고, 새 화면의 시작이 된다.
|
|
108
|
+
* 오버레이가 열려 있으면 아무것도 하지 않는다 — 아래 라우트는 inert 이고 사용자의 포커스는 패널에 있다.
|
|
109
|
+
*
|
|
110
|
+
* 판정은 본문 요소들의 첫 렌더를 기다린 뒤에 한다 — `route-done` 은 화면이 자기 템플릿을
|
|
111
|
+
* 그리기 전에 올 수 있고, 그때 찾으면 `[autofocus]` 가 아직 없다.
|
|
112
|
+
*/
|
|
113
|
+
private focusRoute;
|
|
101
114
|
/** 라우트 에러 핸들러 */
|
|
102
115
|
private handleRouteError;
|
|
103
116
|
private handleOverlaySlotChange;
|
|
@@ -118,9 +131,17 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
|
|
|
118
131
|
*/
|
|
119
132
|
private restoreOverlayFocus;
|
|
120
133
|
/**
|
|
121
|
-
* 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를
|
|
122
|
-
*
|
|
123
|
-
*
|
|
134
|
+
* 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를 낸다 — 단, **안쪽 층이 모두 판정한 뒤에**.
|
|
135
|
+
*
|
|
136
|
+
* 셸의 오버레이는 가장 바깥 층이다. 그런데 window 의 같은 단계 리스너는 «등록 순서» 로 돌고,
|
|
137
|
+
* 셸은 오버레이가 열릴 때 등록하므로 패널 안에서 «나중에» 연 층(`u-drawer`·`u-dialog` — 역시
|
|
138
|
+
* window 에서 받는다)보다 먼저 돈다. 거기서 판정하면 바깥이 닫히고 안쪽이 남는다(거꾸로다).
|
|
139
|
+
* ⇒ 리스너에서는 판정하지 않고 **디스패치가 끝난 뒤**(다음 태스크) `defaultPrevented` 를 본다 —
|
|
140
|
+
* 그때는 어느 순서로 등록된 층이든 이미 자기 몫을 가져갔다. 한 번의 Escape 는 한 층만 닫는다.
|
|
141
|
+
* ⚠마이크로태스크로는 부족하다 — 네이티브 이벤트에서는 리스너 사이마다 마이크로태스크가 비워져,
|
|
142
|
+
* 뒤에 등록된 층보다 먼저 돈다.
|
|
143
|
+
* ⚠셸은 `preventDefault` 하지 않는다 — 가장 바깥 층이 키를 «먹었다» 고 표시하면 안쪽 층의 가드
|
|
144
|
+
* (`defaultPrevented` 면 닫지 않는다)를 오염시킨다.
|
|
124
145
|
* 경로에 패널이 없으면(사이드바, 패널 위에 띄운 대화상자) 받지 않는다.
|
|
125
146
|
*/
|
|
126
147
|
private handleOverlayEscape;
|
|
@@ -52,7 +52,28 @@ function D(e, t) {
|
|
|
52
52
|
}
|
|
53
53
|
return !1;
|
|
54
54
|
}
|
|
55
|
-
|
|
55
|
+
function O(e, t) {
|
|
56
|
+
let n = (e) => {
|
|
57
|
+
let r = e instanceof Element ? [e] : Array.from(e.children);
|
|
58
|
+
for (let e of r) {
|
|
59
|
+
let r = document.createTreeWalker(e, NodeFilter.SHOW_ELEMENT);
|
|
60
|
+
for (let e = r.currentNode; e; e = r.nextNode()) {
|
|
61
|
+
if (e.matches(t)) return e;
|
|
62
|
+
if (e.shadowRoot) {
|
|
63
|
+
let t = n(e.shadowRoot);
|
|
64
|
+
if (t) return t;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
};
|
|
70
|
+
for (let t of e) {
|
|
71
|
+
let e = n(t);
|
|
72
|
+
if (e) return e;
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
var k = class extends i {
|
|
56
77
|
static {
|
|
57
78
|
b = this;
|
|
58
79
|
}
|
|
@@ -71,7 +92,7 @@ var O = class extends i {
|
|
|
71
92
|
this.progressBarEl.removeAttribute("visible");
|
|
72
93
|
}, 300);
|
|
73
94
|
let t = this.shadowRoot?.querySelector(".main");
|
|
74
|
-
t && (this.config?.scrollBehavior?.(e.context, t), this.
|
|
95
|
+
t && (this.config?.scrollBehavior?.(e.context, t), this.focusRoute(t));
|
|
75
96
|
}, this.handleRouteError = (e) => {
|
|
76
97
|
this.progressBarEl.status = "error", this.progressBarEl.value = 100, setTimeout(() => {
|
|
77
98
|
this.progressBarEl.removeAttribute("visible"), this.progressBarEl.status = "default";
|
|
@@ -86,9 +107,11 @@ var O = class extends i {
|
|
|
86
107
|
}, this.handleNoticeSlotChange = (e) => {
|
|
87
108
|
this.hasNotice = o(e.target);
|
|
88
109
|
}, this.handleOverlayEscape = (e) => {
|
|
89
|
-
if (e.key !== "Escape" || e.
|
|
110
|
+
if (e.key !== "Escape" || e.isComposing || !this.hasOverlay) return;
|
|
90
111
|
let t = this.shadowRoot?.querySelector(".overlay");
|
|
91
|
-
t && e.composedPath().includes(t) && (
|
|
112
|
+
t && e.composedPath().includes(t) && setTimeout(() => {
|
|
113
|
+
!e.defaultPrevented && this.hasOverlay && this.handleOverlayClose();
|
|
114
|
+
}, 0);
|
|
92
115
|
}, this.handleOverlayClose = () => {
|
|
93
116
|
this.fire("overlay-close", { cancelable: !1 });
|
|
94
117
|
}, this._handleMainKeydown = (e) => {
|
|
@@ -207,7 +230,9 @@ var O = class extends i {
|
|
|
207
230
|
<u-progress-bar part="progress"></u-progress-bar>
|
|
208
231
|
|
|
209
232
|
<div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
|
|
210
|
-
<div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
|
|
233
|
+
<div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
|
|
234
|
+
role=${this.config.noticesAriaLabel ? "region" : u}
|
|
235
|
+
aria-label=${this.config.noticesAriaLabel ?? u}>
|
|
211
236
|
<slot name="notice" @slotchange=${this.handleNoticeSlotChange}></slot>
|
|
212
237
|
</div>
|
|
213
238
|
<slot></slot>
|
|
@@ -331,6 +356,19 @@ var O = class extends i {
|
|
|
331
356
|
</u-link>
|
|
332
357
|
`;
|
|
333
358
|
}
|
|
359
|
+
routeContent() {
|
|
360
|
+
return (this.shadowRoot?.querySelector(".main-content > slot:not([name])"))?.assignedElements({ flatten: !0 }) ?? [];
|
|
361
|
+
}
|
|
362
|
+
async focusRoute(e) {
|
|
363
|
+
let t = this.routeContent();
|
|
364
|
+
if (await Promise.all(t.map((e) => e.updateComplete)), this.hasOverlay || !e.isConnected) return;
|
|
365
|
+
let n = E();
|
|
366
|
+
if (n && t.some((e) => D(e, n))) return;
|
|
367
|
+
let r = O(t, "[autofocus]");
|
|
368
|
+
r?.focus();
|
|
369
|
+
let i = E();
|
|
370
|
+
r && i && D(r, i) || e.focus({ preventScroll: !0 });
|
|
371
|
+
}
|
|
334
372
|
overlayPanels() {
|
|
335
373
|
return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
|
|
336
374
|
}
|
|
@@ -357,6 +395,6 @@ var O = class extends i {
|
|
|
357
395
|
r([f({
|
|
358
396
|
type: String,
|
|
359
397
|
reflect: !0
|
|
360
|
-
}), n("design:type", Object)],
|
|
398
|
+
}), n("design:type", Object)], k.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], k.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], k.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", y === void 0 ? Object : y)], k.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], k.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], k.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], k.prototype, "context", void 0), k = b = r([d("u-sidebar-layout")], k);
|
|
361
399
|
//#endregion
|
|
362
|
-
export {
|
|
400
|
+
export { k as SidebarLayout };
|
|
@@ -78,6 +78,13 @@ export interface SidebarLayoutConfig {
|
|
|
78
78
|
* 별개 값이다. 미지정 시 종전과 동일하게 랜드마크 이름이 없다(breaking 아님).
|
|
79
79
|
*/
|
|
80
80
|
mainAriaLabel?: string;
|
|
81
|
+
/**
|
|
82
|
+
* 앱 수준 공지 스택(`slot="notice"`)의 접근 가능한 이름. 지정하면 스택이 `role="region"` +
|
|
83
|
+
* `aria-label` 인 랜드마크가 되어, 스크린리더 사용자가 «지금 떠 있는 공지들» 로 건너뛰어
|
|
84
|
+
* 다시 읽을 수 있다(`region` 은 이름이 있을 때만 랜드마크다). 공지가 없으면 스택 자체가 없으므로
|
|
85
|
+
* 빈 랜드마크는 생기지 않는다. 미지정 시 종전과 같이 이름 없는 컨테이너다.
|
|
86
|
+
*/
|
|
87
|
+
noticesAriaLabel?: string;
|
|
81
88
|
/**
|
|
82
89
|
* 메뉴 항목 권한 필터 판정. 지정하면 `requirePermission`/`requireAnyPermission` 를 만족하지
|
|
83
90
|
* 않는 항목을 숨기고, 항목이 모두 걸러진 section/group 은 통째로 숨긴다.
|
package/package.json
CHANGED
|
@@ -21,10 +21,13 @@ interface SidebarLayoutConfig {
|
|
|
21
21
|
/** Accessible name for the main nav landmark (`<nav class="sidebar-main">`), reflected as `aria-label`. Unset by default. */
|
|
22
22
|
mainAriaLabel?: string;
|
|
23
23
|
|
|
24
|
+
/** Accessible name for the app-level notice stack (`slot="notice"`). When set, the stack is a `role="region"` landmark with this `aria-label`. Unset by default. */
|
|
25
|
+
noticesAriaLabel?: string;
|
|
26
|
+
|
|
24
27
|
/** Permission filter — hides items (and emptied section/groups) whose requirement fails. Unset shows everything. See "권한 기반 메뉴 필터" below. */
|
|
25
28
|
hasPermission?: (code: string) => boolean;
|
|
26
29
|
|
|
27
|
-
/** Called on `route-done` (before focus
|
|
30
|
+
/** Called on `route-done` (before the shell places focus — see *Focus when a route finishes*) with the new route's `RouteContext` and the scroll container itself — implement reset/save/restore here. Unset does nothing (same default as Vue Router's unset `scrollBehavior`). Also reachable outside the hook via the element's `.mainElement` accessor. */
|
|
28
31
|
scrollBehavior?: (context: RouteContext, main: HTMLElement) => void;
|
|
29
32
|
|
|
30
33
|
/** Shell chrome icon source and names — `{ lib?, menu?, close?, sidebarToggle?, overlayClose? }`, only the keys you set replace the defaults. Defaults come from the bundled `internal` set, so the shell needs no network to draw its own toggles; give `lib` **and** the names to point it at your own set. */
|
|
@@ -330,6 +333,23 @@ told apart in CSS; notices are rare and short-lived.
|
|
|
330
333
|
While the overlay is open, `part="main-content"` becomes a box at the area's height, because a
|
|
331
334
|
stacking context needs one — route content is inert and under the overlay then.
|
|
332
335
|
|
|
336
|
+
### Focus when a route finishes
|
|
337
|
+
|
|
338
|
+
On every `route-done` the shell places focus so the keyboard works on the new screen without a
|
|
339
|
+
click — by the same rule as the overlay panel (see *What the shell owns, and what it does not*):
|
|
340
|
+
|
|
341
|
+
1. **If your screen already moved focus into the route content** (a `focus()` in `firstUpdated`,
|
|
342
|
+
say), the shell leaves it there.
|
|
343
|
+
2. **Else an `[autofocus]` element in the route content** — found through shadow roots, so an input
|
|
344
|
+
inside your page component's own template counts. The shell waits for the screen's first render
|
|
345
|
+
before looking.
|
|
346
|
+
3. **Else `part="main"`**, the scroll container — arrow keys and Page Down scroll the new screen,
|
|
347
|
+
and it marks where the new screen starts.
|
|
348
|
+
|
|
349
|
+
So a screen that must receive input on entry — a barcode scan field, a search box — only needs
|
|
350
|
+
`autofocus` on that control. While the overlay is open the shell does none of this: the route
|
|
351
|
+
underneath is inert and focus stays in the panel.
|
|
352
|
+
|
|
333
353
|
## Printing
|
|
334
354
|
|
|
335
355
|
On print media the shell drops its app chrome and lets the content flow across pages: the
|
|
@@ -396,7 +416,7 @@ from either re-implementing what the shell already does, or dropping what it doe
|
|
|
396
416
|
| Keeping the route mounted underneath | shell | the overlay is independent of routing |
|
|
397
417
|
| Moving focus into the panel when it opens | shell | an `[autofocus]` element in your panel, else its first input control, else `part="overlay-close"`. If you already moved focus into the panel, the shell leaves it there |
|
|
398
418
|
| Restoring focus when it closes | shell | back to the control that held focus when the panel opened — only if focus fell to `<body>`; if you moved it somewhere on purpose, that stands. With no such control (opened from code), focus goes to `part="main"` |
|
|
399
|
-
| Escape to close | shell | Escape inside the panel fires the same `overlay-close` as the button — you still empty the slot.
|
|
419
|
+
| Escape to close | shell | Escape inside the panel fires the same `overlay-close` as the button — you still empty the slot. The shell decides **after** every layer inside the panel has: an Escape consumed there (`preventDefault()` — an open list, popover, drawer or dialog, whenever it was opened) closes only that layer. The shell does not mark the Escape consumed itself |
|
|
400
420
|
| **A backdrop / dimmed scrim** | **consumer** | the panel is opaque and full-bleed by design; add a scrim inside your panel if you want one |
|
|
401
421
|
| **Announcing the panel to assistive tech** | **consumer** | put `role`/`aria-label` (or `aria-modal`, if you have made it modal) on *your* panel — the shell does not know what it holds |
|
|
402
422
|
|
|
@@ -435,6 +455,10 @@ Put them in `slot="notice"`; the shell stacks them at the top of the route conte
|
|
|
435
455
|
- **Full width.** Each notice takes the width of the content box, whatever its text length.
|
|
436
456
|
- **Empty takes no space** — the stack has no margin while nothing is slotted.
|
|
437
457
|
- **Inert with the route content** while the overlay is open.
|
|
458
|
+
- **A named landmark on request.** Set `noticesAriaLabel` in the layout config and the stack
|
|
459
|
+
becomes a `role="region"` with that name, so screen-reader users can jump back to the notices
|
|
460
|
+
currently shown. Put the notices in the slot directly — wrapping them in your own named section
|
|
461
|
+
puts the stack's spacing and width on the wrapper instead of on each notice.
|
|
438
462
|
- Which surface a notice is (toast, banner or modal) is decided by *who ends it* — a notice the time
|
|
439
463
|
ends is a toast (`app.success`), not a slotted banner.
|
|
440
464
|
|