@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
- * window 버블 단계에서 받는다 — 패널 안의 목록·팝오버가 자기 층을 닫으며 먹은 키
123
- * (`defaultPrevented`)는 그 층의 몫이다. 한 번의 Escape 는 한 층만 닫는다.
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
- var O = class extends i {
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.hasOverlay || t.focus({ preventScroll: !0 }));
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.defaultPrevented || e.isComposing || !this.hasOverlay) return;
110
+ if (e.key !== "Escape" || e.isComposing || !this.hasOverlay) return;
90
111
  let t = this.shadowRoot?.querySelector(".overlay");
91
- t && e.composedPath().includes(t) && (e.preventDefault(), this.handleOverlayClose());
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)], O.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], O.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], O.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", y === void 0 ? Object : y)], O.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], O.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], O.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], O.prototype, "context", void 0), O = b = r([d("u-sidebar-layout")], O);
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 { O as SidebarLayout };
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
@@ -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.29.0",
4
+ "version": "0.30.1",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -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 moves to the container) 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. */
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. An Escape already consumed inside the panel (an open list or popover calling `preventDefault()`) closes only that layer |
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