@iyulab/modern-app 0.28.2 → 0.30.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,50 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.30.0] - 2026-09-27
4
+
5
+ ### Added
6
+
7
+ - **`noticesAriaLabel`** in the sidebar layout config. When set, the app-level notice stack
8
+ (`slot="notice"`) is a `role="region"` landmark with that name, so screen-reader users can jump
9
+ back to the notices currently shown. Without it, the only way to name the stack was to wrap the
10
+ notices in your own labelled section — which moved the stack's spacing and full width from each
11
+ notice onto the wrapper. The same shape as `mainAriaLabel`; unset keeps today's unnamed container,
12
+ and an empty stack is not rendered, so no empty landmark appears.
13
+
14
+ ### Fixed
15
+
16
+ - **Escape inside the overlay now closes a drawer or dialog opened in the panel before the
17
+ overlay.** 0.28.0 promised that an Escape consumed inside the panel closes only that layer, but
18
+ that held only for layers listening before the shell: the shell listens on `window` from the
19
+ moment the overlay opens, so a `u-drawer` or `u-dialog` opened afterwards listened later. The
20
+ shell judged first, closed the overlay and marked the key consumed — and the drawer, seeing it
21
+ consumed, stayed open: the outer layer closed and the inner one remained. The shell now decides
22
+ after every layer has, and no longer marks the Escape consumed.
23
+
24
+ ## [0.29.0] - 2026-09-27
25
+
26
+ ### Fixed
27
+
28
+ - 🔴 **A filling screen with a long table works again — do not use 0.28.2.** 0.28.2 made the
29
+ `main-content` wrapper a grid box with `min-height: 100%` to keep the bottom gutter on long
30
+ screens. A grid track is sized from its item's content, and during that sizing a screen's
31
+ `height: 100%` counts as `auto` — so a screen that fills the area with a table holding more rows
32
+ than fit (a toolbar plus `flex: 1; min-height: 0`) grew to every row, and the table never
33
+ scrolled. The wrapper now creates no box at all (`display: contents`), and neither does the
34
+ outlet (`@iyulab/router` 0.16.0, now required): route content sits directly in the scroll
35
+ container, which is the only arrangement where filling screens, filling screens with long tables
36
+ and long flowing screens all behave — including the bottom gutter 0.28.2 set out to restore.
37
+
38
+ ### Changed
39
+
40
+ - `::part(main-content)` no longer takes box styling (`padding`, `background`, `border`) outside
41
+ the overlay; style `::part(main)` or the screen instead. While the overlay is open it becomes a
42
+ box at the area's height — the overlay's stacking boundary — as before.
43
+ - With an app notice showing, a screen that fills the area overflows by the notice's height (as in
44
+ 0.28.1). 0.28.2 avoided that only by breaking long tables; the two kinds of screen cannot be told
45
+ apart in CSS.
46
+ - Requires `@iyulab/router` `^0.16.0`, where `<u-outlet>` also has no box.
47
+
3
48
  ## [0.28.2] - 2026-09-27
4
49
 
5
50
  ### Fixed
@@ -118,9 +118,17 @@ export declare class SidebarLayout extends StyledElement<SidebarParts> {
118
118
  */
119
119
  private restoreOverlayFocus;
120
120
  /**
121
- * 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를 낸다.
122
- * window 버블 단계에서 받는다 — 패널 안의 목록·팝오버가 자기 층을 닫으며 먹은 키
123
- * (`defaultPrevented`)는 그 층의 몫이다. 한 번의 Escape 는 한 층만 닫는다.
121
+ * 패널 안의 Escape 는 닫기 버튼과 같은 `overlay-close` 를 낸다 — 단, **안쪽 층이 모두 판정한 뒤에**.
122
+ *
123
+ * 셸의 오버레이는 가장 바깥 층이다. 그런데 window 의 같은 단계 리스너는 «등록 순서» 로 돌고,
124
+ * 셸은 오버레이가 열릴 때 등록하므로 패널 안에서 «나중에» 연 층(`u-drawer`·`u-dialog` — 역시
125
+ * window 에서 받는다)보다 먼저 돈다. 거기서 판정하면 바깥이 닫히고 안쪽이 남는다(거꾸로다).
126
+ * ⇒ 리스너에서는 판정하지 않고 **디스패치가 끝난 뒤**(다음 태스크) `defaultPrevented` 를 본다 —
127
+ * 그때는 어느 순서로 등록된 층이든 이미 자기 몫을 가져갔다. 한 번의 Escape 는 한 층만 닫는다.
128
+ * ⚠마이크로태스크로는 부족하다 — 네이티브 이벤트에서는 리스너 사이마다 마이크로태스크가 비워져,
129
+ * 뒤에 등록된 층보다 먼저 돈다.
130
+ * ⚠셸은 `preventDefault` 하지 않는다 — 가장 바깥 층이 키를 «먹었다» 고 표시하면 안쪽 층의 가드
131
+ * (`defaultPrevented` 면 닫지 않는다)를 오염시킨다.
124
132
  * 경로에 패널이 없으면(사이드바, 패널 위에 띄운 대화상자) 받지 않는다.
125
133
  */
126
134
  private handleOverlayEscape;
@@ -86,9 +86,11 @@ var O = class extends i {
86
86
  }, this.handleNoticeSlotChange = (e) => {
87
87
  this.hasNotice = o(e.target);
88
88
  }, this.handleOverlayEscape = (e) => {
89
- if (e.key !== "Escape" || e.defaultPrevented || e.isComposing || !this.hasOverlay) return;
89
+ if (e.key !== "Escape" || e.isComposing || !this.hasOverlay) return;
90
90
  let t = this.shadowRoot?.querySelector(".overlay");
91
- t && e.composedPath().includes(t) && (e.preventDefault(), this.handleOverlayClose());
91
+ t && e.composedPath().includes(t) && setTimeout(() => {
92
+ !e.defaultPrevented && this.hasOverlay && this.handleOverlayClose();
93
+ }, 0);
92
94
  }, this.handleOverlayClose = () => {
93
95
  this.fire("overlay-close", { cancelable: !1 });
94
96
  }, this._handleMainKeydown = (e) => {
@@ -206,8 +208,10 @@ var O = class extends i {
206
208
  <div class="main" part="main" scrollable tabindex="-1" @keydown=${this._handleMainKeydown}>
207
209
  <u-progress-bar part="progress"></u-progress-bar>
208
210
 
209
- <div class="main-content ${this.hasNotice ? "has-notice" : ""}" part="main-content" ?inert=${this.hasOverlay}>
210
- <div class="notices ${this.hasNotice ? "" : "empty"}" part="notices">
211
+ <div class="main-content" part="main-content" ?inert=${this.hasOverlay}>
212
+ <div class="notices ${this.hasNotice ? "" : "empty"}" part="notices"
213
+ role=${this.config.noticesAriaLabel ? "region" : u}
214
+ aria-label=${this.config.noticesAriaLabel ?? u}>
211
215
  <slot name="notice" @slotchange=${this.handleNoticeSlotChange}></slot>
212
216
  </div>
213
217
  <slot></slot>
@@ -208,29 +208,23 @@ var t = e`
208
208
  }
209
209
 
210
210
  /*
211
- * 라우트 본문을 감싸는 래퍼. 동시에 두 가지여야 한다:
212
- * ⑴ 높이를 «물려준다» — 자손(아웃렛 → 화면)의 height: 100% 가 여기서 풀린다.
213
- * ⑵ 넘치는 화면과 «함께 자란다» — .main 은 스크롤 영역의 끝 padding 을 in-flow
214
- * 자식(= 이 래퍼) 뒤에 붙이므로, 래퍼가 화면보다 작게 고정되면 화면이 래퍼 밖으로
215
- * 넘치고 스크롤 끝의 아래 거터가 사라진다.
216
- * height: 100% 는 ⑴만, min-height: 100% 단독은 ⑵만 준다(백분율 높이는 부모의 «height»
217
- * 가 명시됐을 때만 풀린다 — 최솟값은 그 조건이 아니다). grid 만 둘을 함께 준다: 그리드
218
- * 항목의 기본 stretch 는 백분율 해석이 아니라 «영역 채우기» 라 ⑴이 성립하고, 트랙 높이가
219
- * auto 라 ⑵대로 자란다. 아웃렛(:where(u-outlet))이 같은 규칙을 쓴다.
220
- * ⚠align-content 를 선언하지 말 것 — 규칙이 기본값 normal(= auto 트랙 늘리기)에 기댄다.
211
+ * 라우트 본문을 감싸는 래퍼 — 평소에는 «상자를 만들지 않는다»(display: contents).
212
+ *
213
+ * 높이가 정해진 스크롤 컨테이너(.main) 안에서 라우트 화면은 세 형태다: ⑴채움(height: 100%)
214
+ * ⑵채움-큼(채우되 안의 표가 뷰포트보다 많은 행을 자기 안에서 굴린다) ⑶흐름(길어지고 .main 이
215
+ * 스크롤한다). 사이에 상자가 있으면 셋 중 하나가 반드시 깨진다(실측):
216
+ * · height: 100% → 상자가 못 박혀 ⑶의 화면이 밖으로 넘치고, .main 은 끝 padding 을 in-flow
217
+ * 자식(= 이 래퍼) 뒤에 붙이므로 스크롤 끝의 거터가 사라진다.
218
+ * · grid + min-height: 100% → ⑶은 되지만 트랙이 항목의 내용 크기로 잡히고 그 계산 동안
219
+ * height: 100% 는 auto 로 취급되어, ⑵의 표가 모든 행 높이로 자란다.
220
+ * 상자가 없으면 화면이 .main 을 직접 받는다. 아웃렛(u-outlet)도 같은 이유로 상자가 없다.
221
+ *
222
+ * ⚠공지(slot="notice")가 있으면 채우는 화면은 그 높이만큼 넘친다 — 공지는 본문 위 흐름 안에
223
+ * 있고, 채우는 화면의 height: 100% 는 공지를 모른다. 두 형태를 CSS 로 가를 수 없어 알려진
224
+ * 한계로 둔다(공지는 드물고 잠깐이다).
221
225
  */
222
226
  .main-content {
223
- display: grid;
224
- min-height: 100%;
225
- }
226
- /*
227
- * 공지가 있으면 그 행은 내용 높이로 묶는다. 늘리기는 max 크기가 auto 인 트랙에만
228
- * 적용되므로 max-content 행은 늘어나지 않고, 남는 높이는 뒤따르는 본문 행이 받는다.
229
- * 공지가 없으면(.notices 가 display: none) 이 규칙도 없어야 한다 — 있으면 본문의 첫
230
- * 자식이 이 행에 앉아 채우기를 잃는다.
231
- */
232
- .main-content.has-notice {
233
- grid-template-rows: max-content;
227
+ display: contents;
234
228
  }
235
229
 
236
230
  /*
@@ -285,6 +279,11 @@ var t = e`
285
279
  * 경계에 걸리지 않고, 오버레이가 열린 채로 일어나는 라우트 전환을 계속 보고한다.
286
280
  */
287
281
  :host(:state(overlay)) .main-content {
282
+ /* 쌓임 맥락은 상자가 있어야 생긴다 — 오버레이가 열린 동안만 상자를 둔다(본문은 inert 이고
283
+ 오버레이 아래에 있다). height: 100% 라 채우는 화면은 그대로이고, 흐름 화면의 끝 거터만 그동안
284
+ 빠진다. */
285
+ display: block;
286
+ height: 100%;
288
287
  isolation: isolate;
289
288
  }
290
289
 
@@ -365,12 +364,6 @@ var t = e`
365
364
  padding: 0;
366
365
  overflow: visible;
367
366
  }
368
- /* 인쇄는 쪽 나눔이 흐름을 따라야 하므로 grid 를 풀고 높이를 내용에 맡긴다
369
- (아웃렛의 인쇄 규칙과 같은 이유 — grid 상자 안 끝 블록 여백이 쪽 경계를 넘는다). */
370
- .main-content {
371
- display: block;
372
- min-height: 0;
373
- }
374
367
  :host(:state(overlay)) .main-content {
375
368
  display: none;
376
369
  }
@@ -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.28.2",
4
+ "version": "0.30.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "web-framework",
@@ -59,7 +59,7 @@
59
59
  "test:react-types": "tsc -p tsconfig.react-smoke.json"
60
60
  },
61
61
  "dependencies": {
62
- "@iyulab/router": "^0.15.1",
62
+ "@iyulab/router": "^0.16.0",
63
63
  "i18next": "^26.3.6",
64
64
  "lit": "^3.3.3"
65
65
  },
@@ -21,6 +21,9 @@ 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
 
@@ -260,7 +263,7 @@ Parts available for `styles` overrides on the root layout:
260
263
  | `sidebar-main` | Scrollable main nav area |
261
264
  | `sidebar-footer` | Pinned footer area |
262
265
  | `main` | Main content area (the scroll container) |
263
- | `main-content` | Wrapper holding route content inside `main` — passes the height down and grows with long content (see [Route content area](#route-content-area)); the shell puts `inert` here while the overlay is open |
266
+ | `main-content` | Wrapper holding route content inside `main` — creates no box, so route content sits directly in `main` (see [Route content area](#route-content-area)); the shell puts `inert` here while the overlay is open, and gives it a box then as the overlay's stacking boundary |
264
267
  | `progress` | Top progress bar |
265
268
  | `overlay` | Route-independent overlay panel above `main` |
266
269
  | `overlay-close` | Overlay's close button |
@@ -307,17 +310,28 @@ u-sidebar-layout::part(main) { padding: 24px; }
307
310
  ⚠ `layout.styles` values are inline styles, so they also apply on print media (see [Printing](#printing));
308
311
  use `::part(main)` inside `@media screen` if the change is for the screen only.
309
312
 
310
- Inside it, `part="main-content"` wraps the route content and does two things at once:
313
+ Inside it, the route content sits **directly** in that scroll container: neither
314
+ `part="main-content"` nor `<u-outlet>` creates a box (`display: contents`). That is what lets all
315
+ three kinds of screen work:
316
+
317
+ | Screen | Behaviour |
318
+ |---|---|
319
+ | **Fills the area** — `height: 100%`, or a layout such as `u-master-detail-layout` | gets the area minus its gutters |
320
+ | **Fills the area, more content than fits** — a toolbar plus a table with `flex: 1; min-height: 0` and more rows than the viewport | stays at the area's height; the table scrolls inside itself |
321
+ | **Flows** — a form or document taller than the area | `main` scrolls, and the bottom gutter stays at the end of the scroll |
311
322
 
312
- - **passes the height down** — a screen that fills the area (`height: 100%`, or a layout such as
313
- `u-master-detail-layout`) resolves against the area minus its gutters;
314
- - **grows with long content** — a screen taller than the area scrolls inside `main` and keeps the
315
- bottom gutter at the end of the scroll.
323
+ Any box in between breaks one of them — a box pinned at `height: 100%` loses the bottom gutter on
324
+ flowing screens, and a grid box with `min-height: 100%` grows a filling screen to every row of its
325
+ table. So `::part(main-content)` accepts no box styling (`padding`, `background`, `border` do
326
+ nothing); style `::part(main)` or the screen instead.
316
327
 
317
- App notices (`slot="notice"`) sit at the top of this wrapper at their own height; a filling screen
318
- gets the height that remains below them.
328
+ ⚠ **App notices push a filling screen down.** Notices (`slot="notice"`) sit at the top of the
329
+ content at their own height, and a screen's `height: 100%` does not know about them, so while a
330
+ notice shows, a filling screen overflows by the notice's height. The two kinds of screen cannot be
331
+ told apart in CSS; notices are rare and short-lived.
319
332
 
320
- ⚠ Do not set `align-content` on `::part(main-content)` — the height hand-down relies on its default.
333
+ While the overlay is open, `part="main-content"` becomes a box at the area's height, because a
334
+ stacking context needs one — route content is inert and under the overlay then.
321
335
 
322
336
  ## Printing
323
337
 
@@ -385,7 +399,7 @@ from either re-implementing what the shell already does, or dropping what it doe
385
399
  | Keeping the route mounted underneath | shell | the overlay is independent of routing |
386
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 |
387
401
  | 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"` |
388
- | 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 |
402
+ | 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 |
389
403
  | **A backdrop / dimmed scrim** | **consumer** | the panel is opaque and full-bleed by design; add a scrim inside your panel if you want one |
390
404
  | **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 |
391
405
 
@@ -424,6 +438,10 @@ Put them in `slot="notice"`; the shell stacks them at the top of the route conte
424
438
  - **Full width.** Each notice takes the width of the content box, whatever its text length.
425
439
  - **Empty takes no space** — the stack has no margin while nothing is slotted.
426
440
  - **Inert with the route content** while the overlay is open.
441
+ - **A named landmark on request.** Set `noticesAriaLabel` in the layout config and the stack
442
+ becomes a `role="region"` with that name, so screen-reader users can jump back to the notices
443
+ currently shown. Put the notices in the slot directly — wrapping them in your own named section
444
+ puts the stack's spacing and width on the wrapper instead of on each notice.
427
445
  - Which surface a notice is (toast, banner or modal) is decided by *who ends it* — a notice the time
428
446
  ends is a toast (`app.success`), not a slotted banner.
429
447