@yuneta/gobj-ui 7.25.6 → 7.25.8

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.
@@ -111,6 +111,22 @@ function maybe_apply_translator($node, opts)
111
111
  * Non-blocking, auto-dismiss after `opts.timeout` ms (default
112
112
  * 5000). `opts.timeout = 0` disables auto-dismiss.
113
113
  * Returns `{ close() }` so callers can dismiss programmatically.
114
+ *
115
+ * ONE toast per message on screen: a string message of the same
116
+ * kind as a toast still showing is not stacked under it. One
117
+ * close of a transport settles every request it cut, each view
118
+ * shows its failure, and the operator got a column of identical
119
+ * "the connection dropped". A message built of nodes is never
120
+ * merged: its text is not a key.
121
+ *
122
+ * A repeat is still a CALLER of its own: it gets its own handle
123
+ * and its own time, and the toast stays while any caller holds
124
+ * it -- until every handle is closed or has timed out. Handed the
125
+ * first caller's handle and timer, a repeat asking `timeout: 0`
126
+ * was dismissed on the first one's timer, and closing one
127
+ * caller's handle closed a toast another was still showing
128
+ * (third independent review). The operator's ✕ closes it for
129
+ * everybody.
114
130
  ***************************************************************/
115
131
  function show_notification(shell, kind, message, opts)
116
132
  {
@@ -120,6 +136,16 @@ function show_notification(shell, kind, message, opts)
120
136
  return { close: () => {} };
121
137
  }
122
138
 
139
+ let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
140
+ let key = (typeof message === "string") ? `${kind}\u0000${message}` : null;
141
+ if(key !== null) {
142
+ for(let $shown of Array.from($layer.children)) {
143
+ if($shown.__toast_key__ === key && $shown.__toast__) {
144
+ return $shown.__toast__.hold(timeout);
145
+ }
146
+ }
147
+ }
148
+
123
149
  let p_attrs = (typeof message === "string")
124
150
  ? {i18n: message}
125
151
  : {};
@@ -135,34 +161,57 @@ function show_notification(shell, kind, message, opts)
135
161
  $layer.appendChild($note);
136
162
  maybe_apply_translator($note, opts);
137
163
 
138
- let timeout_id = null;
164
+ let holders = new Set();
139
165
  let closed = false;
140
166
 
141
- let close = function() {
167
+ let close_toast = function() {
142
168
  if(closed) {
143
169
  return;
144
170
  }
145
171
  closed = true;
146
- if(timeout_id) {
147
- clearTimeout(timeout_id);
148
- timeout_id = null;
172
+ for(let holder of holders) {
173
+ if(holder.timeout_id) {
174
+ clearTimeout(holder.timeout_id);
175
+ holder.timeout_id = null;
176
+ }
149
177
  }
178
+ holders.clear();
150
179
  if($note.parentNode) {
151
180
  $note.parentNode.removeChild($note);
152
181
  }
153
182
  };
154
183
 
184
+ /* One caller of this toast: its handle, and its time. */
185
+ let hold = function(ms) {
186
+ let holder = {timeout_id: null};
187
+ let release = function() {
188
+ if(!holders.has(holder)) {
189
+ return;
190
+ }
191
+ if(holder.timeout_id) {
192
+ clearTimeout(holder.timeout_id);
193
+ holder.timeout_id = null;
194
+ }
195
+ holders.delete(holder);
196
+ if(holders.size === 0) {
197
+ close_toast();
198
+ }
199
+ };
200
+ holders.add(holder);
201
+ if(ms > 0) {
202
+ holder.timeout_id = setTimeout(release, ms);
203
+ }
204
+ return { close: release };
205
+ };
206
+
155
207
  let $del = $note.querySelector(".delete");
156
208
  if($del) {
157
- $del.addEventListener("click", close);
158
- }
159
-
160
- let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
161
- if(timeout > 0) {
162
- timeout_id = setTimeout(close, timeout);
209
+ $del.addEventListener("click", close_toast);
163
210
  }
164
211
 
165
- return { close };
212
+ $note.__toast_key__ = key;
213
+ $note.__toast__ = {hold: hold};
214
+ return hold(timeout);
166
215
  }
167
216
 
168
217
 
@@ -0,0 +1,125 @@
1
+ /***********************************************************************
2
+ * shell_toast_once.test.js
3
+ *
4
+ * A toast that says what one already on screen says is not
5
+ * stacked under it: one close of the transport settles every
6
+ * request it cut, each view that asked shows its failure, and
7
+ * the operator got a column of identical "the connection
8
+ * dropped" (low of the independent review of the 2nd round).
9
+ *
10
+ * And a repeat is a caller of its own (third independent
11
+ * review): its handle and its time are its own, and the toast
12
+ * stays while any caller still holds it.
13
+ *
14
+ * Copyright (c) 2026, ArtGins.
15
+ * All Rights Reserved.
16
+ ***********************************************************************/
17
+ import {describe, test, expect, beforeEach, afterEach, vi} from "vitest";
18
+ import {install_dom_double} from "../test/dom_double.js";
19
+
20
+ install_dom_double();
21
+
22
+ const {
23
+ yui_shell_show_error,
24
+ yui_shell_show_info,
25
+ } = await import("./shell_modals.js");
26
+
27
+ let shell = null;
28
+
29
+ function toasts()
30
+ {
31
+ return shell.priv.layers.notification.children;
32
+ }
33
+
34
+ beforeEach(() => {
35
+ shell = {priv: {layers: {notification: document.createElement("div")}}};
36
+ });
37
+
38
+ describe("one toast per message on screen", () => {
39
+
40
+ test("the same error twice is ONE toast", () => {
41
+ yui_shell_show_error(shell, "the connection dropped");
42
+ yui_shell_show_error(shell, "the connection dropped");
43
+ yui_shell_show_error(shell, "the connection dropped");
44
+ expect(toasts().length).toBe(1);
45
+ });
46
+
47
+ test("different messages, or kinds, still stack", () => {
48
+ yui_shell_show_error(shell, "the connection dropped");
49
+ yui_shell_show_error(shell, "the connection dropped during the write");
50
+ yui_shell_show_info(shell, "the connection dropped");
51
+ expect(toasts().length).toBe(3);
52
+ });
53
+
54
+ test("closing the one on screen: the next one is shown", () => {
55
+ const first = yui_shell_show_error(shell, "the connection dropped");
56
+ first.close();
57
+ expect(toasts().length).toBe(0);
58
+ yui_shell_show_error(shell, "the connection dropped");
59
+ expect(toasts().length).toBe(1);
60
+ });
61
+
62
+ test("the toast goes when EVERY handle of it is closed", () => {
63
+ const first = yui_shell_show_error(shell, "the connection dropped");
64
+ const again = yui_shell_show_error(shell, "the connection dropped");
65
+ again.close();
66
+ expect(toasts().length).toBe(1); /* the first still holds it */
67
+ first.close();
68
+ expect(toasts().length).toBe(0);
69
+ });
70
+ });
71
+
72
+ /*
73
+ * The third independent review: a repeat was handed the FIRST toast's
74
+ * handle and timer. A repeat asking `timeout: 0` was dismissed on the
75
+ * first one's timer, and closing one caller's handle closed the toast
76
+ * another caller was still showing.
77
+ */
78
+ describe("a repeat is its own caller", () => {
79
+
80
+ beforeEach(() => {
81
+ vi.useFakeTimers();
82
+ });
83
+ afterEach(() => {
84
+ vi.useRealTimers();
85
+ });
86
+
87
+ test("a repeat asking timeout 0 is not dismissed on the first one's timer", () => {
88
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
89
+ const sticky = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
90
+ vi.advanceTimersByTime(5000);
91
+ expect(toasts().length).toBe(1);
92
+ sticky.close();
93
+ expect(toasts().length).toBe(0);
94
+ });
95
+
96
+ test("closing the first caller's handle leaves the repeat's toast up", () => {
97
+ const first = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
98
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 3000});
99
+ first.close();
100
+ expect(toasts().length).toBe(1);
101
+ vi.advanceTimersByTime(3000);
102
+ expect(toasts().length).toBe(0);
103
+ });
104
+
105
+ test("the longest time wins, as a restart did", () => {
106
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
107
+ vi.advanceTimersByTime(800);
108
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
109
+ vi.advanceTimersByTime(500);
110
+ expect(toasts().length).toBe(1);
111
+ vi.advanceTimersByTime(500);
112
+ expect(toasts().length).toBe(0);
113
+ });
114
+
115
+ test("the operator's close takes it down for every caller", () => {
116
+ const first = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
117
+ const again = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
118
+ toasts()[0].querySelector(".TOAST_CLOSE").click();
119
+ expect(toasts().length).toBe(0);
120
+ first.close();
121
+ again.close();
122
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
123
+ expect(toasts().length).toBe(1);
124
+ });
125
+ });