@iyulab/modern-app 0.30.0 → 0.30.2

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,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.2] - 2026-09-28
4
+
5
+ ### Fixed
6
+
7
+ - **An overlay panel that is a component now gets focus inside it.** The shell looked for the
8
+ panel's `[autofocus]` element and first input control with `querySelector`, which stops at shadow
9
+ roots — so when the panel was a custom element rendering its controls in its own template, the
10
+ shell found neither and fell back to the close button. The search now goes through shadow roots
11
+ and waits for the panel's first render, as route focus does since 0.30.1.
12
+ - **Route and overlay focus skip a `u-select`'s closed popover.** Searching through shadow roots
13
+ (0.30.1) also reached the popover every `u-select` keeps inside, which carries an `autofocus`
14
+ attribute of its own — a screen with a `u-select` before its `autofocus` field got the content
15
+ area instead. Only elements that can actually take focus are candidates now.
16
+
17
+ ### Changed
18
+
19
+ - **Requires `@iyulab/components` 1.47.1 or later** (peer) — the shell now uses its
20
+ `querySelectorDeep` / `isFocusCandidate`, the same lookup `u-dialog` and `u-drawer` use.
21
+
22
+ ## [0.30.1] - 2026-09-28
23
+
24
+ ### Fixed
25
+
26
+ - **A route screen can declare its first focus.** On every route change the shell moved focus to
27
+ the content area unconditionally, so a screen that focused its own input on entry — a scan field,
28
+ a search box — had that focus taken back, and `autofocus` in the screen did nothing. Route
29
+ changes now follow the overlay panel's rule: focus the screen already moved into the content
30
+ stays; else an `[autofocus]` element in the content (through shadow roots, after the screen's
31
+ first render) receives it; else the content area, as before.
32
+
3
33
  ## [0.30.0] - 2026-09-27
4
34
 
5
35
  ### Added
@@ -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;
@@ -109,6 +122,7 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
109
122
  * `[autofocus]` → 첫 입력 컨트롤 → (없으면) 셸의 닫기 버튼.
110
123
  * 닫기 버튼으로 떨어지는 것은 의도다 — 포커스가 `<body>` 에 남는 것보다 항상 낫고,
111
124
  * 셸이 늘 가진 유일한 컨트롤이다. 소비자가 이미 패널 안으로 옮겨 두었으면 건드리지 않는다.
125
+ * 탐색은 섀도 경계 안쪽까지 한다 — 패널이 컴포넌트면 컨트롤은 그 섀도 루트에 있다.
112
126
  */
113
127
  private focusOverlay;
114
128
  /**
@@ -19,32 +19,33 @@ import { unsafeHTML as h } from "lit/directives/unsafe-html.js";
19
19
  import { repeat as g } from "lit/directives/repeat.js";
20
20
  import { ifDefined as _ } from "lit/directives/if-defined.js";
21
21
  import { createDevWarner as v } from "@iyulab/components/dist/utilities/devWarning.js";
22
- import { UProgressBar as y } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
22
+ import { isFocusCandidate as y, querySelectorDeep as b } from "@iyulab/components/dist/utilities/elements.js";
23
+ import { UProgressBar as x } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
23
24
  //#region src/layouts/SidebarLayout.ts
24
- var b, x = v("@iyulab/modern-app"), S = 0;
25
- function C(e) {
25
+ var S, C = v("@iyulab/modern-app"), w = 0;
26
+ function T(e) {
26
27
  let t = e.tagName;
27
28
  if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
28
29
  let n = e.getAttribute("role");
29
30
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
30
31
  }
31
- function w(e) {
32
+ function E(e) {
32
33
  let t = getComputedStyle(e);
33
34
  return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
34
35
  }
35
- function T(e, t) {
36
+ function D(e, t) {
36
37
  for (let n of e) if (n instanceof HTMLElement) {
37
- if (w(n)) return n === t;
38
+ if (E(n)) return n === t;
38
39
  if (n === t) break;
39
40
  }
40
41
  return !0;
41
42
  }
42
- function E() {
43
+ function O() {
43
44
  let e = document.activeElement;
44
45
  for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
45
46
  return e;
46
47
  }
47
- function D(e, t) {
48
+ function k(e, t) {
48
49
  let n = t;
49
50
  for (; n;) {
50
51
  if (n === e) return !0;
@@ -52,12 +53,12 @@ function D(e, t) {
52
53
  }
53
54
  return !1;
54
55
  }
55
- var O = class extends i {
56
+ var A = class extends i {
56
57
  static {
57
- b = this;
58
+ S = this;
58
59
  }
59
60
  constructor(...t) {
60
- super(...t), this.state = "default", this.locale = "", this.hasOverlay = !1, this.hasNotice = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++S}`, 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.handleToggleButtonClick = () => {
61
+ super(...t), this.state = "default", this.locale = "", this.hasOverlay = !1, this.hasNotice = !1, this.overlayTrigger = null, this.internals = typeof this.attachInternals == "function" ? this.attachInternals() : void 0, this.context = null, this.unsizedWarnKey = `unsized:${++w}`, 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.handleToggleButtonClick = () => {
61
62
  let t = this.screenSize ?? e.screen ?? "large";
62
63
  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);
63
64
  }, this.handleBackdropClick = () => {
@@ -71,7 +72,7 @@ var O = class extends i {
71
72
  this.progressBarEl.removeAttribute("visible");
72
73
  }, 300);
73
74
  let t = this.shadowRoot?.querySelector(".main");
74
- t && (this.config?.scrollBehavior?.(e.context, t), this.hasOverlay || t.focus({ preventScroll: !0 }));
75
+ t && (this.config?.scrollBehavior?.(e.context, t), this.focusRoute(t));
75
76
  }, this.handleRouteError = (e) => {
76
77
  this.progressBarEl.status = "error", this.progressBarEl.value = 100, setTimeout(() => {
77
78
  this.progressBarEl.removeAttribute("visible"), this.progressBarEl.status = "default";
@@ -79,7 +80,7 @@ var O = class extends i {
79
80
  }, this.handleOverlaySlotChange = (e) => {
80
81
  let t = o(e.target);
81
82
  if (t && !this.hasOverlay) {
82
- let e = E();
83
+ let e = O();
83
84
  this.overlayTrigger = e && e !== document.body ? e : null;
84
85
  }
85
86
  this.hasOverlay = t;
@@ -95,9 +96,9 @@ var O = class extends i {
95
96
  this.fire("overlay-close", { cancelable: !1 });
96
97
  }, this._handleMainKeydown = (e) => {
97
98
  let t = e.composedPath()[0];
98
- if (t instanceof HTMLElement && C(t)) return;
99
+ if (t instanceof HTMLElement && T(t)) return;
99
100
  let n = this.shadowRoot?.querySelector(".main");
100
- if (!n || !T(e.composedPath(), n)) return;
101
+ if (!n || !D(e.composedPath(), n)) return;
101
102
  let r = n.clientHeight;
102
103
  switch (e.key) {
103
104
  case " ":
@@ -152,7 +153,7 @@ var O = class extends i {
152
153
  process.env.NODE_ENV !== "production" && requestAnimationFrame(() => {
153
154
  if (!this.isConnected) return;
154
155
  let e = this.getBoundingClientRect().height;
155
- e >= 200 || x(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).`);
156
+ e >= 200 || C(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).`);
156
157
  });
157
158
  }
158
159
  render() {
@@ -304,7 +305,7 @@ var O = class extends i {
304
305
  };
305
306
  }
306
307
  icon(e) {
307
- return this.config?.icons?.[e] ?? b.DEFAULT_ICONS[e];
308
+ return this.config?.icons?.[e] ?? S.DEFAULT_ICONS[e];
308
309
  }
309
310
  renderLogo() {
310
311
  let e = this.config?.logo, t = this.config?.title ?? "Home";
@@ -335,32 +336,38 @@ var O = class extends i {
335
336
  </u-link>
336
337
  `;
337
338
  }
339
+ routeContent() {
340
+ return (this.shadowRoot?.querySelector(".main-content > slot:not([name])"))?.assignedElements({ flatten: !0 }) ?? [];
341
+ }
342
+ async focusRoute(e) {
343
+ let t = this.routeContent();
344
+ if (await Promise.all(t.map((e) => e.updateComplete)), this.hasOverlay || !e.isConnected) return;
345
+ let n = O();
346
+ if (n && t.some((e) => k(e, n))) return;
347
+ let r = b(t, "[autofocus]", y);
348
+ r?.focus();
349
+ let i = O();
350
+ r && i && k(r, i) || e.focus({ preventScroll: !0 });
351
+ }
338
352
  overlayPanels() {
339
353
  return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
340
354
  }
341
- focusOverlay() {
342
- let e = this.overlayPanels(), t = E();
343
- if (t && e.some((e) => D(e, t))) return;
344
- let n = (t) => {
345
- for (let n of e) {
346
- if (n.matches(t)) return n;
347
- let e = n.querySelector(t);
348
- if (e) return e;
349
- }
350
- return null;
351
- };
352
- (n("[autofocus]") ?? n("input, select, textarea, u-input, u-textarea, u-select, u-checkbox, u-radio, u-switch, u-slider") ?? this.shadowRoot?.querySelector(".overlay-close"))?.focus();
355
+ async focusOverlay() {
356
+ let e = this.overlayPanels();
357
+ if (await Promise.all(e.map((e) => e.updateComplete)), !this.hasOverlay) return;
358
+ let t = O();
359
+ t && e.some((e) => k(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();
353
360
  }
354
361
  restoreOverlayFocus() {
355
362
  let e = this.overlayTrigger;
356
363
  this.overlayTrigger = null;
357
- let t = E();
364
+ let t = O();
358
365
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
359
366
  }
360
367
  };
361
368
  r([f({
362
369
  type: String,
363
370
  reflect: !0
364
- }), 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);
371
+ }), n("design:type", Object)], A.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], A.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], A.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", x === void 0 ? Object : x)], A.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], A.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], A.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], A.prototype, "context", void 0), A = S = r([d("u-sidebar-layout")], A);
365
372
  //#endregion
366
- export { O as SidebarLayout };
373
+ export { A as SidebarLayout };
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.30.0",
4
+ "version": "0.30.2",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -64,7 +64,7 @@
64
64
  "lit": "^3.3.3"
65
65
  },
66
66
  "peerDependencies": {
67
- "@iyulab/components": ">=1.45.2",
67
+ "@iyulab/components": ">=1.47.1",
68
68
  "@lit/react": ">=1.0.8",
69
69
  "react": ">=18.0.0"
70
70
  },
@@ -89,6 +89,6 @@
89
89
  "vite": "^8.1.4",
90
90
  "vite-plugin-dts": "^5.0.3",
91
91
  "vitest": "^5.0.0",
92
- "@iyulab/components": ">=1.45.2"
92
+ "@iyulab/components": ">=1.47.1"
93
93
  }
94
94
  }
@@ -27,7 +27,7 @@ interface SidebarLayoutConfig {
27
27
  /** Permission filter — hides items (and emptied section/groups) whose requirement fails. Unset shows everything. See "권한 기반 메뉴 필터" below. */
28
28
  hasPermission?: (code: string) => boolean;
29
29
 
30
- /** 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. */
31
31
  scrollBehavior?: (context: RouteContext, main: HTMLElement) => void;
32
32
 
33
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. */
@@ -333,6 +333,23 @@ told apart in CSS; notices are rare and short-lived.
333
333
  While the overlay is open, `part="main-content"` becomes a box at the area's height, because a
334
334
  stacking context needs one — route content is inert and under the overlay then.
335
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
+
336
353
  ## Printing
337
354
 
338
355
  On print media the shell drops its app chrome and lets the content flow across pages: the
@@ -397,7 +414,7 @@ from either re-implementing what the shell already does, or dropping what it doe
397
414
  | Making route content non-interactive | shell | `inert` on `part="main-content"`, which propagates through the slot into your route content |
398
415
  | A close affordance | shell | `part="overlay-close"`, firing the non-cancelable `overlay-close` event |
399
416
  | Keeping the route mounted underneath | shell | the overlay is independent of routing |
400
- | 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 |
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"` — searched through shadow roots, after the panel's first render, so a panel that is itself a component works the same. If you already moved focus into the panel, the shell leaves it there |
401
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"` |
402
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 |
403
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 |