@iyulab/modern-app 0.31.0 → 0.33.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 CHANGED
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.33.0] - 2026-09-30
4
+
5
+ ### Added
6
+
7
+ - **«Skip to main content» link in the sidebar layout** — the shell's first Tab stop, shown only while
8
+ it has focus (`part="skip-link"`). Activating it moves focus the way a finished route does: to the
9
+ screen's `[autofocus]` element, else to the route area. A keyboard user no longer tabs through the
10
+ whole menu on every screen (WCAG 2.4.1 Bypass Blocks). The text is the new `skipToContent` key of
11
+ `modernAppLocale` (English and Korean built in).
12
+
13
+ ### Changed
14
+
15
+ - **The route area (`part="main"`) is a `<main>` element**, so screen readers list it as the main
16
+ landmark. It was a `<div>`. Styling through `::part(main)` and `layout.styles` is unchanged. If your
17
+ route screens render their own `<main>`, change it to a `<div>` or `<section>` — a page should have
18
+ one main landmark.
19
+
20
+ ## [0.32.0] - 2026-09-28
21
+
22
+ ### Changed
23
+
24
+ - **The shell's overlay panel is a layer in `@iyulab/components`' layer stack.** Escape inside the
25
+ panel still fires `overlay-close`, and a drawer, dialog or popover opened inside the panel still
26
+ closes first — the order now comes from `OverlayManager` (layers stack in the order they opened)
27
+ instead of the shell waiting a task to see who else handled the key. The Escape that closes the
28
+ panel is now marked consumed (`defaultPrevented`), like every layer's; an Escape pressed outside
29
+ the panel is still left alone. Requires `@iyulab/components` 1.49.0 or later.
30
+ - The sidebar and the mobile header read `--u-layer-shell` (1000 and one above it, as before), the
31
+ shared stacking token from `@iyulab/components`.
32
+
3
33
  ## [0.31.0] - 2026-09-28
4
34
 
5
35
  ### Changed
package/README.md CHANGED
@@ -276,6 +276,7 @@ conformance claim for the success criteria it does not list.
276
276
  |---|---|---|
277
277
  | SC 2.5.8 Target Size (Minimum) | Every pointer target this package renders itself — sidebar links, buttons and group headers (expanded and compact), the sidebar logo and toggle, wizard steps, the page header's back link — is at least 24×24 CSS px or meets the spacing exception (24px between centers), and is actually hit at that position | `tests/browser/target-size.browser.test.ts` (real Chromium) |
278
278
  | SC 2.1.1 Keyboard (pointer-cursor check) | Nothing this package renders shows a pointer cursor without being an interactive element — the sidebar logo is a link | `tests/browser/target-size.browser.test.ts` |
279
+ | SC 2.4.1 Bypass Blocks | The sidebar layout's first Tab stop is a «Skip to main content» link, visible when focused and at least 24px tall; it moves focus to the screen's `[autofocus]` element or to the route area, which is the page's single `<main>` landmark | `tests/browser/sidebar-layout-skip-link.browser.test.ts` |
279
280
 
280
281
  Buttons placed through slots or rendered as `u-button` without size overrides (wizard back/next, the
281
282
  master-detail close button) follow `@iyulab/components`, which measures them in its own gate. Color
@@ -13,6 +13,8 @@ export interface ModernAppLocaleStrings {
13
13
  toggleMobileMenu: string;
14
14
  /** Sidebar layout — accessible label for the sidebar-header collapse/expand toggle button. */
15
15
  toggleSidebar: string;
16
+ /** Sidebar layout — the «skip to main content» link, the first Tab stop of the shell. */
17
+ skipToContent: string;
16
18
  /** Wizard — default Back/Next action labels ("Submit" wording is the consumer's call). */
17
19
  wizardBack: string;
18
20
  wizardNext: string;
@@ -9,6 +9,7 @@ var t = {
9
9
  detailClose: "Close",
10
10
  toggleMobileMenu: "Toggle menu",
11
11
  toggleSidebar: "Toggle sidebar",
12
+ skipToContent: "Skip to main content",
12
13
  wizardBack: "Back",
13
14
  wizardNext: "Next",
14
15
  wizardStepAnnouncement: "Step {index} of {total}: {label}"
@@ -22,6 +23,7 @@ n.register("en", t), n.register("ko", {
22
23
  detailClose: "닫기",
23
24
  toggleMobileMenu: "메뉴 열고 닫기",
24
25
  toggleSidebar: "사이드바 열고 닫기",
26
+ skipToContent: "본문으로 건너뛰기",
25
27
  wizardBack: "이전",
26
28
  wizardNext: "다음",
27
29
  wizardStepAnnouncement: "{total}단계 중 {index}단계: {label}"
@@ -53,6 +55,7 @@ function l(e) {
53
55
  detailClose: t("detailClose"),
54
56
  toggleMobileMenu: t("toggleMobileMenu"),
55
57
  toggleSidebar: t("toggleSidebar"),
58
+ skipToContent: t("skipToContent"),
56
59
  wizardBack: t("wizardBack"),
57
60
  wizardNext: t("wizardNext"),
58
61
  wizardStepAnnouncement: (t, n, r) => {
@@ -111,6 +111,10 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
111
111
  * 그리기 전에 올 수 있고, 그때 찾으면 `[autofocus]` 가 아직 없다.
112
112
  */
113
113
  private focusRoute;
114
+ /** 본문의 시작으로 포커스 — 화면이 선언한 `[autofocus]`, 없으면 본문(`main`) 자체. */
115
+ private focusContentStart;
116
+ /** «본문 바로가기» — 라우트 완료와 같은 규칙으로 본문의 시작에 포커스를 둔다(`#517`). */
117
+ private handleSkipLinkClick;
114
118
  /** 라우트 에러 핸들러 */
115
119
  private handleRouteError;
116
120
  private handleOverlaySlotChange;
@@ -132,18 +136,13 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
132
136
  */
133
137
  private restoreOverlayFocus;
134
138
  /**
135
- * 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를 낸다 — 단, **안쪽 층이 모두 판정한 뒤에**.
139
+ * 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를 낸다.
136
140
  *
137
- * 셸의 오버레이는 가장 바깥 층이다. 그런데 window 의 같은 단계 리스너는 «등록 순서» 로 돌고,
138
- * 셸은 오버레이가 열릴 때 등록하므로 패널 안에서 «나중에» 연 층(`u-drawer`·`u-dialog` — 역시
139
- * window 에서 받는다)보다 먼저 돈다. 거기서 판정하면 바깥이 닫히고 안쪽이 남는다(거꾸로다).
140
- * ⇒ 리스너에서는 판정하지 않고 **디스패치가 끝난 뒤**(다음 태스크) `defaultPrevented` 를 본다 —
141
- * 그때는 어느 순서로 등록된 층이든 이미 자기 몫을 가져갔다. 한 번의 Escape 는 한 층만 닫는다.
142
- * ⚠마이크로태스크로는 부족하다 — 네이티브 이벤트에서는 리스너 사이마다 마이크로태스크가 비워져,
143
- * 뒤에 등록된 층보다 먼저 돈다.
144
- * ⚠셸은 `preventDefault` 하지 않는다 — 가장 바깥 층이 키를 «먹었다» 고 표시하면 안쪽 층의 가드
145
- * (`defaultPrevented` 면 닫지 않는다)를 오염시킨다.
146
- * 경로에 패널이 없으면(사이드바, 패널 위에 띄운 대화상자) 받지 않는다.
141
+ * 패널은 components 의 층 스택(`OverlayManager.openLayer`)에 선 한 층이다 — 층은 여는 순서로 쌓이고
142
+ * Escape 한 번은 가장 위 층 하나만 닫으므로, 패널 «안» 에서 나중에 연 서랍·대화상자·팝오버가 먼저
143
+ * 닫힌다. 종전에는 셸이 window 에서 직접 받아 «등록 순서» 가 층 순서를 거슬렀고, 그것을 다음
144
+ * 태스크로 미룬 판정으로 메웠다 — 이제 순서는 한 곳이 정한다.
145
+ * 비모달 패널이라 **포커스가 패널 안일 때만** 닫는다(사이드바에서 누른 Escape 는 거절 — 소비하지 않는다).
147
146
  */
148
147
  private handleOverlayEscape;
149
148
  private handleOverlayClose;
@@ -20,32 +20,33 @@ 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
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
+ import { OverlayManager as x } from "@iyulab/components/dist/utilities/OverlayManager.js";
24
+ import { UProgressBar as S } from "@iyulab/components/dist/components/progress-bar/UProgressBar.js";
24
25
  //#region src/layouts/SidebarLayout.ts
25
- var S, C = v("@iyulab/modern-app"), w = 0;
26
- function T(e) {
26
+ var C, w = v("@iyulab/modern-app"), T = 0;
27
+ function E(e) {
27
28
  let t = e.tagName;
28
29
  if (t === "INPUT" || t === "TEXTAREA" || t === "SELECT" || e.isContentEditable) return !0;
29
30
  let n = e.getAttribute("role");
30
31
  return n === "textbox" || n === "searchbox" || n === "combobox" || n === "spinbutton";
31
32
  }
32
- function E(e) {
33
+ function D(e) {
33
34
  let t = getComputedStyle(e);
34
35
  return (t.overflowY === "auto" || t.overflowY === "scroll") && e.scrollHeight > e.clientHeight;
35
36
  }
36
- function D(e, t) {
37
+ function O(e, t) {
37
38
  for (let n of e) if (n instanceof HTMLElement) {
38
- if (E(n)) return n === t;
39
+ if (D(n)) return n === t;
39
40
  if (n === t) break;
40
41
  }
41
42
  return !0;
42
43
  }
43
- function O() {
44
+ function k() {
44
45
  let e = document.activeElement;
45
46
  for (; e?.shadowRoot?.activeElement;) e = e.shadowRoot.activeElement;
46
47
  return e;
47
48
  }
48
- function k(e, t) {
49
+ function A(e, t) {
49
50
  let n = t;
50
51
  for (; n;) {
51
52
  if (n === e) return !0;
@@ -53,12 +54,12 @@ function k(e, t) {
53
54
  }
54
55
  return !1;
55
56
  }
56
- var A = class extends i {
57
+ var j = class extends i {
57
58
  static {
58
- S = this;
59
+ C = this;
59
60
  }
60
61
  constructor(...t) {
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 = () => {
62
+ 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:${++T}`, 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 = () => {
62
63
  let t = this.screenSize ?? e.screen ?? "large";
63
64
  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);
64
65
  }, this.handleBackdropClick = () => {
@@ -73,6 +74,10 @@ var A = class extends i {
73
74
  }, 300);
74
75
  let t = this.shadowRoot?.querySelector(".main");
75
76
  t && (this.config?.scrollBehavior?.(e.context, t), this.focusRoute(t));
77
+ }, this.handleSkipLinkClick = (e) => {
78
+ e.preventDefault();
79
+ let t = this.shadowRoot?.querySelector(".main");
80
+ t && this.focusContentStart(this.hasOverlay ? [] : this.routeContent(), t);
76
81
  }, this.handleRouteError = (e) => {
77
82
  this.progressBarEl.status = "error", this.progressBarEl.value = 100, setTimeout(() => {
78
83
  this.progressBarEl.removeAttribute("visible"), this.progressBarEl.status = "default";
@@ -80,25 +85,22 @@ var A = class extends i {
80
85
  }, this.handleOverlaySlotChange = (e) => {
81
86
  let t = o(e.target);
82
87
  if (t && !this.hasOverlay) {
83
- let e = O();
88
+ let e = k();
84
89
  this.overlayTrigger = e && e !== document.body ? e : null;
85
90
  }
86
91
  this.hasOverlay = t;
87
92
  }, this.handleNoticeSlotChange = (e) => {
88
93
  this.hasNotice = o(e.target);
89
94
  }, this.handleOverlayEscape = (e) => {
90
- if (e.key !== "Escape" || e.isComposing || !this.hasOverlay) return;
91
95
  let t = this.shadowRoot?.querySelector(".overlay");
92
- t && e.composedPath().includes(t) && setTimeout(() => {
93
- !e.defaultPrevented && this.hasOverlay && this.handleOverlayClose();
94
- }, 0);
96
+ return !this.hasOverlay || !t || !e.composedPath().includes(t) ? !1 : (this.handleOverlayClose(), !0);
95
97
  }, this.handleOverlayClose = () => {
96
98
  this.fire("overlay-close", { cancelable: !1 });
97
99
  }, this._handleMainKeydown = (e) => {
98
100
  let t = e.composedPath()[0];
99
- if (t instanceof HTMLElement && T(t)) return;
101
+ if (t instanceof HTMLElement && E(t)) return;
100
102
  let n = this.shadowRoot?.querySelector(".main");
101
- if (!n || !D(e.composedPath(), n)) return;
103
+ if (!n || !O(e.composedPath(), n)) return;
102
104
  let r = n.clientHeight;
103
105
  switch (e.key) {
104
106
  case " ":
@@ -138,13 +140,13 @@ var A = class extends i {
138
140
  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);
139
141
  }
140
142
  disconnectedCallback() {
141
- window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize), window.removeEventListener("keydown", this.handleOverlayEscape), super.disconnectedCallback();
143
+ window.removeEventListener("route-begin", this.handleRouteBegin), window.removeEventListener("route-done", this.handleRouteDone), window.removeEventListener("route-progress", this.handleRouteProgress), window.removeEventListener("route-error", this.handleRouteError), window.removeEventListener("screen-resize", this.handleScreenResize), x.closeLayer(this), super.disconnectedCallback();
142
144
  }
143
145
  willUpdate(e) {
144
146
  super.willUpdate(e), e.has("config") && (this.styles = this.config?.styles);
145
147
  }
146
148
  updated(e) {
147
- super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (window.addEventListener("keydown", this.handleOverlayEscape), this.focusOverlay()) : e.get("hasOverlay") === !0 && (window.removeEventListener("keydown", this.handleOverlayEscape), this.restoreOverlayFocus()));
149
+ super.updated(e), e.has("hasOverlay") && (this.internals?.states?.[this.hasOverlay ? "add" : "delete"]("overlay"), this.hasOverlay ? (x.openLayer(this, this.handleOverlayEscape), this.focusOverlay()) : e.get("hasOverlay") === !0 && (x.closeLayer(this), this.restoreOverlayFocus()));
148
150
  }
149
151
  firstUpdated(e) {
150
152
  super.firstUpdated(e), this.warnIfUnsized();
@@ -153,11 +155,16 @@ var A = class extends i {
153
155
  process.env.NODE_ENV !== "production" && requestAnimationFrame(() => {
154
156
  if (!this.isConnected) return;
155
157
  let e = this.getBoundingClientRect().height;
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).`);
158
+ e >= 200 || w(this.unsizedWarnKey, `u-sidebar-layout is only ${Math.round(e)}px tall — its height: 100% found no sized ancestor, so the shell sits at its own chrome height and the route area has almost no room. Give the root element a height (e.g. #app { height: 100vh } — app.load() does this for document.body).`);
157
159
  });
158
160
  }
159
161
  render() {
160
162
  return this.config ? l`
163
+ <!-- 반복 영역 건너뛰기(WCAG 2.4.1) — 셸의 첫 Tab. 포커스될 때만 보인다. -->
164
+ <a class="skip-link" part="skip-link" href="#" @click=${this.handleSkipLinkClick}>
165
+ ${a(this.locale || void 0).skipToContent}
166
+ </a>
167
+
161
168
  <!-- Mobile Header -->
162
169
  <div class="mobile-header" part="mobile-header" ?hidden="${!this.state.startsWith("mobile")}">
163
170
  ${this.renderLogo()}
@@ -206,7 +213,7 @@ var A = class extends i {
206
213
 
207
214
  <!-- Main Content -->
208
215
  <div class="main-region">
209
- <div class="main" part="main" scrollable tabindex="-1" @keydown=${this._handleMainKeydown}>
216
+ <main class="main" part="main" scrollable tabindex="-1" @keydown=${this._handleMainKeydown}>
210
217
  <u-progress-bar part="progress"></u-progress-bar>
211
218
 
212
219
  <div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
@@ -217,7 +224,7 @@ var A = class extends i {
217
224
  </div>
218
225
  <slot></slot>
219
226
  </div>
220
- </div>
227
+ </main>
221
228
 
222
229
  <div class="overlay ${this.hasOverlay ? "" : "empty"}" part="overlay">
223
230
  <u-button class="overlay-close" part="overlay-close" variant="ghost"
@@ -305,7 +312,7 @@ var A = class extends i {
305
312
  };
306
313
  }
307
314
  icon(e) {
308
- return this.config?.icons?.[e] ?? S.DEFAULT_ICONS[e];
315
+ return this.config?.icons?.[e] ?? C.DEFAULT_ICONS[e];
309
316
  }
310
317
  renderLogo() {
311
318
  let e = this.config?.logo, t = this.config?.title ?? "Home";
@@ -342,12 +349,14 @@ var A = class extends i {
342
349
  async focusRoute(e) {
343
350
  let t = this.routeContent();
344
351
  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 });
352
+ let n = k();
353
+ n && t.some((e) => A(e, n)) || this.focusContentStart(t, e);
354
+ }
355
+ focusContentStart(e, t) {
356
+ let n = b(e, "[autofocus]", y);
357
+ n?.focus();
358
+ let r = k();
359
+ n && r && A(n, r) || t.focus({ preventScroll: !0 });
351
360
  }
352
361
  overlayPanels() {
353
362
  return (this.shadowRoot?.querySelector("slot[name=\"overlay\"]"))?.assignedElements({ flatten: !0 }) ?? [];
@@ -355,19 +364,19 @@ var A = class extends i {
355
364
  async focusOverlay() {
356
365
  let e = this.overlayPanels();
357
366
  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();
367
+ let t = k();
368
+ t && e.some((e) => A(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();
360
369
  }
361
370
  restoreOverlayFocus() {
362
371
  let e = this.overlayTrigger;
363
372
  this.overlayTrigger = null;
364
- let t = O();
373
+ let t = k();
365
374
  t && t !== document.body || (e?.isConnected ? e.focus() : this.mainElement?.focus({ preventScroll: !0 }));
366
375
  }
367
376
  };
368
377
  r([f({
369
378
  type: String,
370
379
  reflect: !0
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);
380
+ }), n("design:type", Object)], j.prototype, "state", void 0), r([f({ type: Object }), n("design:type", Object)], j.prototype, "config", void 0), r([f({ type: String }), n("design:type", Object)], j.prototype, "locale", void 0), r([p("u-progress-bar"), n("design:type", S === void 0 ? Object : S)], j.prototype, "progressBarEl", void 0), r([m(), n("design:type", Object)], j.prototype, "hasOverlay", void 0), r([m(), n("design:type", Object)], j.prototype, "hasNotice", void 0), r([m(), n("design:type", Object)], j.prototype, "context", void 0), j = C = r([d("u-sidebar-layout")], j);
372
381
  //#endregion
373
- export { A as SidebarLayout };
382
+ export { j as SidebarLayout };
@@ -72,10 +72,39 @@ var t = e`
72
72
  color: var(--u-txt-color-hover, #1565C0);
73
73
  }
74
74
 
75
+ /* 본문 바로가기 — 포커스될 때만 보인다(보이지 않을 때는 자리도 차지하지 않는다). */
76
+ .skip-link {
77
+ position: absolute;
78
+ top: 8px;
79
+ left: 8px;
80
+ z-index: calc(var(--u-layer-shell, 1000) + 2);
81
+ width: 1px;
82
+ height: 1px;
83
+ overflow: hidden;
84
+ clip-path: inset(50%);
85
+ white-space: nowrap;
86
+ }
87
+ .skip-link:focus {
88
+ width: auto;
89
+ height: auto;
90
+ min-height: 24px;
91
+ overflow: visible;
92
+ clip-path: none;
93
+ display: inline-flex;
94
+ align-items: center;
95
+ padding: 6px 12px;
96
+ border-radius: var(--u-radius-md, 4px);
97
+ background: var(--u-bg-color, #FFFFFF);
98
+ color: var(--u-txt-color, #212121);
99
+ box-shadow: 0 0 0 2px var(--u-primary-color, #1976D2);
100
+ text-decoration: none;
101
+ outline: none;
102
+ }
103
+
75
104
  /* Sidebar Container */
76
105
  .sidebar {
77
106
  position: relative;
78
- z-index: 1000;
107
+ z-index: var(--u-layer-shell, 1000);
79
108
  display: flex;
80
109
  flex-direction: column;
81
110
  justify-content: space-between;
@@ -126,7 +155,7 @@ var t = e`
126
155
  /* Mobile Header */
127
156
  .mobile-header {
128
157
  position: relative;
129
- z-index: 1001;
158
+ z-index: calc(var(--u-layer-shell, 1000) + 1);
130
159
  display: flex;
131
160
  flex-direction: row;
132
161
  align-items: center;
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.31.0",
4
+ "version": "0.33.0",
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.47.1",
67
+ "@iyulab/components": ">=1.49.0",
68
68
  "@lit/react": ">=1.0.8",
69
69
  "react": ">=18.0.0"
70
70
  },
@@ -77,7 +77,7 @@
77
77
  }
78
78
  },
79
79
  "devDependencies": {
80
- "@iyulab/components": ">=1.47.1",
80
+ "@iyulab/components": ">=1.49.0",
81
81
  "@lit/react": "^1.0.8",
82
82
  "@types/node": "^26.1.1",
83
83
  "@types/react": "^19.2.14",
@@ -257,6 +257,7 @@ Parts available for `styles` overrides on the root layout:
257
257
  | Part | Element |
258
258
  |------|---------|
259
259
  | `host` | Outer layout shell |
260
+ | `skip-link` | «Skip to main content» link — the shell's first Tab stop, shown only while focused |
260
261
  | `mobile-header` | Top bar shown on mobile |
261
262
  | `sidebar` | Sidebar panel |
262
263
  | `sidebar-header` | Logo + title area |
@@ -333,6 +334,15 @@ told apart in CSS; notices are rare and short-lived.
333
334
  While the overlay is open, `part="main-content"` becomes a box at the area's height, because a
334
335
  stacking context needs one — route content is inert and under the overlay then.
335
336
 
337
+ ### Skip link and main landmark
338
+
339
+ The route area is a `<main>` landmark (`part="main"`), so a screen reader can jump to it. The shell's
340
+ first Tab stop is a **«Skip to main content»** link (`part="skip-link"`, localized — `skipToContent` in
341
+ `modernAppLocale`), visible only while it has focus. Activating it moves focus by the same rule as a
342
+ finished route (steps 2 and 3 below): your screen's `[autofocus]` element, else `main` itself — so a
343
+ keyboard user does not tab through the whole menu on every screen (WCAG 2.4.1 Bypass Blocks). Do not put
344
+ another `<main>` in your route screens; the shell already provides it.
345
+
336
346
  ### Focus when a route finishes
337
347
 
338
348
  On every `route-done` the shell places focus so the keyboard works on the new screen without a
@@ -418,7 +428,7 @@ from either re-implementing what the shell already does, or dropping what it doe
418
428
  | Keeping the route mounted underneath | shell | the overlay is independent of routing |
419
429
  | 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 |
420
430
  | 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"` |
421
- | 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 |
431
+ | Escape to close | shell | Escape inside the panel fires the same `overlay-close` as the button — you still empty the slot. The panel is a layer in `@iyulab/components`' layer stack: layers stack in the order they opened and one Escape closes the topmost, so a list, popover, drawer or dialog opened inside the panel closes first. The Escape that closes the panel is marked consumed (`defaultPrevented`); an Escape pressed outside the panel is left alone. For an app surface of your own, `OverlayManager.openLayer` joins the same order |
422
432
  | **A backdrop / dimmed scrim** | **consumer** | the panel is opaque and full-bleed by design; add a scrim inside your panel if you want one |
423
433
  | **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 |
424
434