@iyulab/components 1.38.0 → 1.39.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,39 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.39.0] - 2026-09-08
4
+
5
+ ### Fixed
6
+
7
+ - **A toast raised while a modal is open is no longer hidden behind it.** `Toast` hardcoded
8
+ `z-index: 9999` on its container while `OverlayManager` assigned open overlays a value
9
+ *above* 9999, so a toast could never appear over an open `u-dialog` or `u-drawer` — the
10
+ most common place an application reports a failure. Stacking is now a contract owned by
11
+ `OverlayManager`: overlays are assigned from a bounded band, and the notification layer sits
12
+ above that band by construction. Read `OverlayManager.notificationZIndex` instead of
13
+ hardcoding a value for any surface that must stay visible over an overlay.
14
+
15
+ - **`Toast` no longer returns a promise that never settles, and no longer drops toasts
16
+ silently.** The container cache was keyed by a string derived from the target
17
+ (`"<position>@<id>"`), which collided when a different element reused the same `id`, never
18
+ cached at all for targets without an `id` (the fallback embedded `Date.now()`), and made the
19
+ cleanup path recompute a key that could miss. A cached container that had left the document
20
+ was still handed back, and an element appended to a detached container never connects — so
21
+ its `updateComplete` never resolves and `await Toast.success(...)` hung forever with no error
22
+ and no toast. Containers are now keyed by the target element itself and validated on every
23
+ cache hit. Passing a target that is not in the document now warns and returns instead of
24
+ hanging.
25
+
26
+ ### Changed
27
+
28
+ - **Overlay z-index is derived from how many overlays are open at once, not from how many have
29
+ ever been opened.** The previous counter increased monotonically and never reset, leaving the
30
+ overlay band unbounded. Relative stacking between simultaneously open overlays is unchanged.
31
+
32
+ ### Added
33
+
34
+ - **`OverlayManager.notificationZIndex`** — the z-index of the notification layer, guaranteed to
35
+ sit above the entire overlay band.
36
+
3
37
  ## [1.38.0] - 2026-09-08
4
38
 
5
39
  ### Changed
@@ -1,17 +1,49 @@
1
1
  import { FocusTrap } from 'focus-trap';
2
2
  /**
3
- * OverlayManager는 열린 오버레이의 스택을 관리합니다.
3
+ * OverlayManager는 **겹치는 표면 전체의 층(layer)을 소유**합니다.
4
4
  *
5
- * - 오버레이 스택 순서 관리 (z-index 자동 증가)
5
+ * - 오버레이 스택 순서 관리 (z-index 자동 할당)
6
+ * - **알림 층 z-index 제공** — 알림은 오버레이의 형제가 아니라 그 «위» 채널이다
6
7
  * - body scroll lock 참조 카운팅
7
8
  * - topmost 판별 (ESC 키 처리용)
8
9
  * - focus-trap trapStack 공유
10
+ *
11
+ * ## 층 스케일 — 왜 두 개의 «띠»인가
12
+ *
13
+ * 겹침을 컴포넌트마다 따로 정하면 어긋난다. 실측(2026-09-08)으로 `Toast` 가 자기 컨테이너에
14
+ * `z-index: 9999` 를 박고 있었고 이 매니저가 오버레이에 **9999 «초과»** 를 주고 있어,
15
+ * ***모달 안에서 띄운 오류 토스트가 구조적으로 항상 가려졌다.*** 알림이 도달하지 않는 것은
16
+ * 조용한 실패라 아무도 보지 못했다.
17
+ *
18
+ * ⇒ 층을 **이름 있는 띠**로 고정한다(디자인 시스템의 표준 관용구다 — 겹침은 협상이 아니라
19
+ * 계약이다):
20
+ *
21
+ * | 띠 | 범위 | 소유 |
22
+ * |---|---|---|
23
+ * | 오버레이 | `9999` ~ `9999 + OVERLAY_BAND` | `u-dialog`·`u-drawer` 등 `UOverlayElement` 계열 |
24
+ * | **알림** | `notificationZIndex` | `Toast` — **오버레이 띠보다 항상 위** |
25
+ *
26
+ * ⚠**띠에 상한이 있는 것이 이 계약의 핵심이다.** 종전 `zCounter` 는 «지금까지 열린 총합»이라
27
+ * 단조 증가해 **상한이 없었고**, 상한이 없으면 «알림이 항상 위»를 어떤 상수로도 보장할 수
28
+ * 없다. 이제 **동시에 열린 깊이**(`stack.length`)로 할당하므로 띠 안에 갇힌다.
29
+ * 동시 오버레이가 `OVERLAY_BAND` 를 넘으면 그 이상은 같은 값을 공유한다(서로 간 겹침 순서만
30
+ * 포기하고, **알림이 위**라는 불변식은 유지된다) — 실제 앱이 도달하는 상태가 아니지만,
31
+ * 도달하더라도 무엇이 깨지는지 정해져 있는 편이 낫다.
9
32
  */
10
33
  export declare class OverlayManager {
11
34
  /** 열린 오버레이 스택 */
12
35
  private static readonly stack;
13
- /** z-index 카운터 */
14
- private static zCounter;
36
+ /** 오버레이 띠의 시작 값 */
37
+ private static readonly OVERLAY_BASE;
38
+ /** 오버레이 띠의 폭 — 이 수를 넘는 «동시» 오버레이는 최상단 값을 공유한다 */
39
+ private static readonly OVERLAY_BAND;
40
+ /**
41
+ * 알림 층의 z-index. **오버레이 띠 전체보다 항상 위**임이 보장된다.
42
+ *
43
+ * 토스트·스낵바처럼 «오버레이 위에서도 반드시 보여야 하는» 표면이 쓴다. 직접 상수를
44
+ * 박지 말고 이 값을 읽을 것 — 그래야 띠가 조정돼도 따라온다.
45
+ */
46
+ static get notificationZIndex(): number;
15
47
  /** body scroll lock 참조 카운트 */
16
48
  private static lockCount;
17
49
  /** scroll lock 이전 body overflow 값 */
@@ -1,18 +1,53 @@
1
1
  //#region src/utilities/OverlayManager.ts
2
2
  /**
3
- * OverlayManager는 열린 오버레이의 스택을 관리합니다.
3
+ * OverlayManager는 **겹치는 표면 전체의 층(layer)을 소유**합니다.
4
4
  *
5
- * - 오버레이 스택 순서 관리 (z-index 자동 증가)
5
+ * - 오버레이 스택 순서 관리 (z-index 자동 할당)
6
+ * - **알림 층 z-index 제공** — 알림은 오버레이의 형제가 아니라 그 «위» 채널이다
6
7
  * - body scroll lock 참조 카운팅
7
8
  * - topmost 판별 (ESC 키 처리용)
8
9
  * - focus-trap trapStack 공유
10
+ *
11
+ * ## 층 스케일 — 왜 두 개의 «띠»인가
12
+ *
13
+ * 겹침을 컴포넌트마다 따로 정하면 어긋난다. 실측(2026-09-08)으로 `Toast` 가 자기 컨테이너에
14
+ * `z-index: 9999` 를 박고 있었고 이 매니저가 오버레이에 **9999 «초과»** 를 주고 있어,
15
+ * ***모달 안에서 띄운 오류 토스트가 구조적으로 항상 가려졌다.*** 알림이 도달하지 않는 것은
16
+ * 조용한 실패라 아무도 보지 못했다.
17
+ *
18
+ * ⇒ 층을 **이름 있는 띠**로 고정한다(디자인 시스템의 표준 관용구다 — 겹침은 협상이 아니라
19
+ * 계약이다):
20
+ *
21
+ * | 띠 | 범위 | 소유 |
22
+ * |---|---|---|
23
+ * | 오버레이 | `9999` ~ `9999 + OVERLAY_BAND` | `u-dialog`·`u-drawer` 등 `UOverlayElement` 계열 |
24
+ * | **알림** | `notificationZIndex` | `Toast` — **오버레이 띠보다 항상 위** |
25
+ *
26
+ * ⚠**띠에 상한이 있는 것이 이 계약의 핵심이다.** 종전 `zCounter` 는 «지금까지 열린 총합»이라
27
+ * 단조 증가해 **상한이 없었고**, 상한이 없으면 «알림이 항상 위»를 어떤 상수로도 보장할 수
28
+ * 없다. 이제 **동시에 열린 깊이**(`stack.length`)로 할당하므로 띠 안에 갇힌다.
29
+ * 동시 오버레이가 `OVERLAY_BAND` 를 넘으면 그 이상은 같은 값을 공유한다(서로 간 겹침 순서만
30
+ * 포기하고, **알림이 위**라는 불변식은 유지된다) — 실제 앱이 도달하는 상태가 아니지만,
31
+ * 도달하더라도 무엇이 깨지는지 정해져 있는 편이 낫다.
9
32
  */
10
33
  var OverlayManager = class {
11
34
  static {
12
35
  this.stack = [];
13
36
  }
14
37
  static {
15
- this.zCounter = 0;
38
+ this.OVERLAY_BASE = 9999;
39
+ }
40
+ static {
41
+ this.OVERLAY_BAND = 1e3;
42
+ }
43
+ /**
44
+ * 알림 층의 z-index. **오버레이 띠 전체보다 항상 위**임이 보장된다.
45
+ *
46
+ * 토스트·스낵바처럼 «오버레이 위에서도 반드시 보여야 하는» 표면이 쓴다. 직접 상수를
47
+ * 박지 말고 이 값을 읽을 것 — 그래야 띠가 조정돼도 따라온다.
48
+ */
49
+ static get notificationZIndex() {
50
+ return this.OVERLAY_BASE + this.OVERLAY_BAND + 1;
16
51
  }
17
52
  static {
18
53
  this.lockCount = 0;
@@ -38,7 +73,7 @@ var OverlayManager = class {
38
73
  */
39
74
  static add(overlay, lockBody = true) {
40
75
  this.stack.push(overlay);
41
- overlay.style.zIndex = String(9999 + ++this.zCounter);
76
+ overlay.style.zIndex = String(this.OVERLAY_BASE + Math.min(this.stack.length, this.OVERLAY_BAND));
42
77
  if (lockBody) {
43
78
  if (this.lockCount === 0) {
44
79
  this.savedOverflow = document.body.style.overflow;
@@ -20,8 +20,20 @@ export interface ToastOptions {
20
20
  * 토스트 알림 유틸리티 클래스입니다.
21
21
  */
22
22
  export declare class Toast {
23
+ /**
24
+ * 위치별 컨테이너를 **타깃 엘리먼트 자신에 키잉**한다.
25
+ *
26
+ * 종전에는 `"<position>@<id>"` 문자열이 키였고, 그 파생이 세 가지를 동시에 깨뜨렸다:
27
+ * ⑴같은 `id`를 가진 «다른» 엘리먼트가 같은 키로 충돌한다(SPA 라우트 교체가 화면
28
+ * 엘리먼트를 같은 `id`로 다시 만드는 것은 평범한 패턴이다) ⑵`id`가 없으면 폴백이
29
+ * `el-${Date.now()}` 라 호출마다 키가 달라져 캐시가 성립하지 않고 컨테이너가 쌓인다
30
+ * ⑶그 불안정한 키를 `hide` 정리 경로가 **다시 계산**하므로 `containers.delete()` 가
31
+ * 빗나가 항목이 영구히 남는다.
32
+ *
33
+ * `WeakMap` 은 셋을 전부 구조적으로 없앤다 — 엘리먼트 동일성이 곧 키라 충돌이 불가능하고,
34
+ * `id` 유무에 의존하지 않으며, 타깃이 버려지면 그 항목도 함께 수거된다.
35
+ */
23
36
  private static containers;
24
- private static elements;
25
37
  /**
26
38
  * 모든 Toast 호출에 적용될 전역 기본 옵션입니다. 개별 호출의 `options`가 우선합니다.
27
39
  *
@@ -47,8 +59,13 @@ export declare class Toast {
47
59
  static error(content: string, options?: ToastOptions): Promise<void>;
48
60
  /** 토스트 알림을 생성합니다. */
49
61
  static show(status?: AlertStatus, content?: string, options?: ToastOptions): Promise<void>;
50
- /** 컨테이너 키를 생성합니다. */
51
- private static getContainerKey;
52
- /** 위치에 맞는 컨테이너 엘리먼트를 가져오거나, 생성합니다. */
62
+ /**
63
+ * 위치에 맞는 컨테이너 엘리먼트를 가져오거나, 생성합니다.
64
+ *
65
+ * ⚠**캐시 적중은 그 컨테이너가 «여전히 쓸 수 있는가»를 확인한 뒤에만 유효하다.** 호스트가
66
+ * 갈아끼워지면(`body.innerHTML = ''`, 셸 재구축) 캐시된 컨테이너는 문서에서 떨어진 채
67
+ * 남는데, 거기에 append 된 엘리먼트는 **연결되지 않아 `updateComplete` 가 영원히 해소되지
68
+ * 않는다**. 낡은 항목은 버리고 새로 만든다.
69
+ */
53
70
  private static getOrCreateContainer;
54
71
  }
@@ -1,3 +1,4 @@
1
+ import { OverlayManager } from "./OverlayManager.js";
1
2
  import { UAlert } from "../components/alert/UAlert.js";
2
3
  //#region src/utilities/Toast.ts
3
4
  /**
@@ -5,10 +6,7 @@ import { UAlert } from "../components/alert/UAlert.js";
5
6
  */
6
7
  var Toast = class {
7
8
  static {
8
- this.containers = /* @__PURE__ */ new Map();
9
- }
10
- static {
11
- this.elements = /* @__PURE__ */ new Set();
9
+ this.containers = /* @__PURE__ */ new WeakMap();
12
10
  }
13
11
  static {
14
12
  this.DefaultOptions = {};
@@ -52,36 +50,46 @@ var Toast = class {
52
50
  el.title = merged.title || "";
53
51
  el.closable = merged.closable ?? true;
54
52
  el.duration = merged.duration ?? 4e3;
55
- this.elements.add(el);
56
53
  const position = merged.position || "top-right";
57
54
  const target = merged.target || document.body;
58
55
  const container = this.getOrCreateContainer(position, target);
59
56
  container.appendChild(el);
57
+ if (!el.isConnected) {
58
+ container.removeChild(el);
59
+ console.warn("[@iyulab/components] Toast was not shown: the target element is not in the document.\n A toast can only render inside a connected element — pass a target that is attached,\n or omit `target` to use document.body.");
60
+ return;
61
+ }
60
62
  await el.updateComplete;
61
63
  el.show();
62
64
  el.addEventListener("hide", async (e) => {
63
65
  if (e.target !== el) return;
64
66
  await new Promise((resolve) => setTimeout(resolve, 200));
65
67
  el.remove();
66
- this.elements.delete(el);
67
68
  if (!container.hasChildNodes()) {
68
- const containerKey = this.getContainerKey(position, target);
69
69
  container.remove();
70
- this.containers.delete(containerKey);
70
+ this.containers.get(target)?.delete(position);
71
71
  }
72
72
  });
73
73
  }
74
- /** 컨테이너 키를 생성합니다. */
75
- static getContainerKey(position, target) {
76
- return `${position}@${target === document.body ? "body" : target.id || `el-${Date.now()}`}`;
77
- }
78
- /** 위치에 맞는 컨테이너 엘리먼트를 가져오거나, 생성합니다. */
74
+ /**
75
+ * 위치에 맞는 컨테이너 엘리먼트를 가져오거나, 생성합니다.
76
+ *
77
+ * ⚠**캐시 적중은 그 컨테이너가 «여전히 쓸 수 있는가»를 확인한 뒤에만 유효하다.** 호스트가
78
+ * 갈아끼워지면(`body.innerHTML = ''`, 셸 재구축) 캐시된 컨테이너는 문서에서 떨어진 채
79
+ * 남는데, 거기에 append 된 엘리먼트는 **연결되지 않아 `updateComplete` 가 영원히 해소되지
80
+ * 않는다**. 낡은 항목은 버리고 새로 만든다.
81
+ */
79
82
  static getOrCreateContainer(position, target) {
80
- const key = this.getContainerKey(position, target);
81
- let container = this.containers.get(key);
82
- if (container) return container;
83
- container = document.createElement("div");
84
- container.style.zIndex = "9999";
83
+ let byPosition = this.containers.get(target);
84
+ if (!byPosition) {
85
+ byPosition = /* @__PURE__ */ new Map();
86
+ this.containers.set(target, byPosition);
87
+ }
88
+ const cached = byPosition.get(position);
89
+ if (cached && cached.isConnected && cached.parentNode === target) return cached;
90
+ if (cached) byPosition.delete(position);
91
+ const container = document.createElement("div");
92
+ container.style.zIndex = String(OverlayManager.notificationZIndex);
85
93
  container.style.display = "flex";
86
94
  container.style.gap = "10px";
87
95
  const isTargeted = target !== document.body;
@@ -114,7 +122,7 @@ var Toast = class {
114
122
  }
115
123
  container.style.transform = transformParts.length ? transformParts.join(" ") : "";
116
124
  target.appendChild(container);
117
- this.containers.set(key, container);
125
+ byPosition.set(position, container);
118
126
  return container;
119
127
  }
120
128
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@iyulab/components",
3
3
  "description": "web-components library based on lit-element made by iyulab",
4
- "version": "1.38.0",
4
+ "version": "1.39.0",
5
5
  "keywords": [
6
6
  "iyulab",
7
7
  "components",
@@ -4,7 +4,20 @@
4
4
  import { OverlayManager } from '@iyulab/components';
5
5
  ```
6
6
 
7
- Internal static manager for the overlay stack. Used by `UOverlayElement` (`u-dialog`, `u-drawer`) to track open overlays, assign z-index, and manage body scroll locking.
7
+ Internal static manager that **owns the layer scale for every stacking surface**. Used by `UOverlayElement` (`u-dialog`, `u-drawer`) to track open overlays, assign z-index, and manage body scroll locking — and read by `Toast` for the notification layer.
8
+
9
+ ## Layer scale
10
+
11
+ Stacking is a contract, not a negotiation between components. Two bands exist, and the notification band is **always above** the overlay band:
12
+
13
+ | Band | Value | Owned by |
14
+ |------|-------|----------|
15
+ | Overlay | `9999 + depth`, capped at `9999 + 1000` | `UOverlayElement` subclasses (`u-dialog`, `u-drawer`, custom overlays) |
16
+ | **Notification** | `OverlayManager.notificationZIndex` | `Toast` |
17
+
18
+ Overlay z-index comes from **how many overlays are open at once** (stack depth), not from how many have ever been opened. That is what bounds the band — and a bounded band is what lets the notification layer sit above it by construction. More than 1000 simultaneous overlays share the top value: they stop stacking relative to each other, but notifications stay above them.
19
+
20
+ > **Do not hardcode a z-index for a surface that must appear above overlays.** Read `OverlayManager.notificationZIndex` so the value follows the scale.
8
21
 
9
22
  > **Note:** You generally do not need to use `OverlayManager` directly. It is called automatically by overlay components. Only use it if you are building a custom overlay component that extends `UOverlayElement`.
10
23
 
@@ -12,10 +25,11 @@ Internal static manager for the overlay stack. Used by `UOverlayElement` (`u-dia
12
25
 
13
26
  | Member | Type / Returns | Description |
14
27
  |--------|----------------|-------------|
15
- | `OverlayManager.add(overlay, lockBody?)` | `void` | Register an overlay; increments z-index; optionally locks body scroll |
28
+ | `OverlayManager.add(overlay, lockBody?)` | `void` | Register an overlay; assigns a z-index from the overlay band by stack depth; optionally locks body scroll |
16
29
  | `OverlayManager.remove(overlay, lockBody?)` | `void` | Unregister an overlay; releases scroll lock if no overlays remain |
17
30
  | `OverlayManager.isTopmost(overlay)` | `boolean` | Returns `true` if the overlay is the topmost in the stack (used for ESC key handling) |
18
31
  | `OverlayManager.size` | `number` | Number of currently open overlays |
32
+ | `OverlayManager.notificationZIndex` | `number` | z-index of the notification layer — guaranteed above the whole overlay band. Use this for toasts, snackbars, and anything else that must remain visible over an open modal |
19
33
  | `OverlayManager.trapStack` | `FocusTrap[]` | Shared focus-trap stack (used internally by focus-trap library) |
20
34
 
21
35
  ## Example (custom overlay)