@yuneta/gobj-ui 7.25.7 → 7.25.9

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.
@@ -113,12 +113,20 @@ function maybe_apply_translator($node, opts)
113
113
  * Returns `{ close() }` so callers can dismiss programmatically.
114
114
  *
115
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 -- that
117
- * toast is kept, its time starts again, and its handle is
118
- * returned. One close of a transport settles every request it
119
- * cut, each view shows its failure, and the operator got a
120
- * column of identical "the connection dropped". A message built
121
- * of nodes is never merged: its text is not a key.
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.
122
130
  ***************************************************************/
123
131
  function show_notification(shell, kind, message, opts)
124
132
  {
@@ -128,12 +136,12 @@ function show_notification(shell, kind, message, opts)
128
136
  return { close: () => {} };
129
137
  }
130
138
 
139
+ let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
131
140
  let key = (typeof message === "string") ? `${kind}\u0000${message}` : null;
132
141
  if(key !== null) {
133
142
  for(let $shown of Array.from($layer.children)) {
134
143
  if($shown.__toast_key__ === key && $shown.__toast__) {
135
- $shown.__toast__.restart();
136
- return $shown.__toast__.handle;
144
+ return $shown.__toast__.hold(timeout);
137
145
  }
138
146
  }
139
147
  }
@@ -145,7 +153,8 @@ function show_notification(shell, kind, message, opts)
145
153
  ["div", {class: `TOAST notification yui-notification ${yui_tint(kind)}`,
146
154
  role: kind === "danger" ? "alert" : "status"},
147
155
  [
148
- ["button", {class: "TOAST_CLOSE delete", "aria-label": t("close"), "data-i18n-aria-label": "close"}],
156
+ ["button", {class: "TOAST_CLOSE delete", title: t("close"), "data-i18n-title": "close",
157
+ "aria-label": t("close"), "data-i18n-aria-label": "close"}],
149
158
  ["p", {...p_attrs, class: "TOAST_MSG"}, message]
150
159
  ]
151
160
  ]
@@ -153,44 +162,57 @@ function show_notification(shell, kind, message, opts)
153
162
  $layer.appendChild($note);
154
163
  maybe_apply_translator($note, opts);
155
164
 
156
- let timeout_id = null;
165
+ let holders = new Set();
157
166
  let closed = false;
158
167
 
159
- let close = function() {
168
+ let close_toast = function() {
160
169
  if(closed) {
161
170
  return;
162
171
  }
163
172
  closed = true;
164
- if(timeout_id) {
165
- clearTimeout(timeout_id);
166
- timeout_id = null;
173
+ for(let holder of holders) {
174
+ if(holder.timeout_id) {
175
+ clearTimeout(holder.timeout_id);
176
+ holder.timeout_id = null;
177
+ }
167
178
  }
179
+ holders.clear();
168
180
  if($note.parentNode) {
169
181
  $note.parentNode.removeChild($note);
170
182
  }
171
183
  };
172
184
 
185
+ /* One caller of this toast: its handle, and its time. */
186
+ let hold = function(ms) {
187
+ let holder = {timeout_id: null};
188
+ let release = function() {
189
+ if(!holders.has(holder)) {
190
+ return;
191
+ }
192
+ if(holder.timeout_id) {
193
+ clearTimeout(holder.timeout_id);
194
+ holder.timeout_id = null;
195
+ }
196
+ holders.delete(holder);
197
+ if(holders.size === 0) {
198
+ close_toast();
199
+ }
200
+ };
201
+ holders.add(holder);
202
+ if(ms > 0) {
203
+ holder.timeout_id = setTimeout(release, ms);
204
+ }
205
+ return { close: release };
206
+ };
207
+
173
208
  let $del = $note.querySelector(".delete");
174
209
  if($del) {
175
- $del.addEventListener("click", close);
210
+ $del.addEventListener("click", close_toast);
176
211
  }
177
212
 
178
- let timeout = (opts && opts.timeout != null) ? opts.timeout : 5000;
179
- let restart = function() {
180
- if(timeout_id) {
181
- clearTimeout(timeout_id);
182
- timeout_id = null;
183
- }
184
- if(timeout > 0) {
185
- timeout_id = setTimeout(close, timeout);
186
- }
187
- };
188
- restart();
189
-
190
- let handle = { close };
191
213
  $note.__toast_key__ = key;
192
- $note.__toast__ = {handle: handle, restart: restart};
193
- return handle;
214
+ $note.__toast__ = {hold: hold};
215
+ return hold(timeout);
194
216
  }
195
217
 
196
218
 
@@ -263,7 +285,8 @@ export function yui_shell_show_modal(shell, content, opts)
263
285
  .concat(title
264
286
  ? [["span", {class: "MODAL_TITLE_KIND", i18n: title}, title]]
265
287
  : [])],
266
- ["button", {class: "MODAL_CLOSE yui-dialog-x", type: "button", "aria-label": t("close"), "data-i18n-aria-label": "close"},
288
+ ["button", {class: "MODAL_CLOSE yui-dialog-x", type: "button", title: t("close"), "data-i18n-title": "close",
289
+ "aria-label": t("close"), "data-i18n-aria-label": "close"},
267
290
  [["i", {class: "yi-xmark"}]]],
268
291
  ]];
269
292
  let body = ["div", {class: "MODAL_BODY yui-dialog-body"}, inner ? [inner] : []];
@@ -283,7 +306,8 @@ export function yui_shell_show_modal(shell, content, opts)
283
306
  if(with_close) {
284
307
  modal_children.push(
285
308
  ["button", {class: "MODAL_CLOSE modal-close is-large",
286
- "aria-label": t("close"), "data-i18n-aria-label": "close"}]
309
+ title: t("close"), "data-i18n-title": "close",
310
+ "aria-label": t("close"), "data-i18n-aria-label": "close"}]
287
311
  );
288
312
  }
289
313
  }
@@ -498,7 +522,8 @@ function build_dialog(shell, message, buttons, opts)
498
522
  );
499
523
  }
500
524
  $card_children.push(
501
- ["button", {class: "CONFIRM_CLOSE delete yui-confirm-x", "aria-label": t("close"), "data-i18n-aria-label": "close"}],
525
+ ["button", {class: "CONFIRM_CLOSE delete yui-confirm-x", title: t("close"), "data-i18n-title": "close",
526
+ "aria-label": t("close"), "data-i18n-aria-label": "close"}],
502
527
  ["section", {class: "CONFIRM_BODY modal-card-body has-text-centered"},
503
528
  $body_children],
504
529
  ["footer", {class: "CONFIRM_FOOT modal-card-foot"}, $footer_children]
@@ -7,10 +7,18 @@
7
7
  * the operator got a column of identical "the connection
8
8
  * dropped" (low of the independent review of the 2nd round).
9
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
+ * And every ✕ this module draws is a control with a NAME: a
15
+ * `title` and an `aria-label`, both translatable (fourth
16
+ * independent review: TOAST_CLOSE had the label only).
17
+ *
10
18
  * Copyright (c) 2026, ArtGins.
11
19
  * All Rights Reserved.
12
20
  ***********************************************************************/
13
- import {describe, test, expect, beforeEach} from "vitest";
21
+ import {describe, test, expect, beforeEach, afterEach, vi} from "vitest";
14
22
  import {install_dom_double} from "../test/dom_double.js";
15
23
 
16
24
  install_dom_double();
@@ -18,6 +26,8 @@ install_dom_double();
18
26
  const {
19
27
  yui_shell_show_error,
20
28
  yui_shell_show_info,
29
+ yui_shell_show_modal,
30
+ yui_shell_confirm_danger,
21
31
  } = await import("./shell_modals.js");
22
32
 
23
33
  let shell = null;
@@ -55,10 +65,103 @@ describe("one toast per message on screen", () => {
55
65
  expect(toasts().length).toBe(1);
56
66
  });
57
67
 
58
- test("the handle of the repeat closes the toast on screen", () => {
59
- yui_shell_show_error(shell, "the connection dropped");
68
+ test("the toast goes when EVERY handle of it is closed", () => {
69
+ const first = yui_shell_show_error(shell, "the connection dropped");
60
70
  const again = yui_shell_show_error(shell, "the connection dropped");
61
71
  again.close();
72
+ expect(toasts().length).toBe(1); /* the first still holds it */
73
+ first.close();
74
+ expect(toasts().length).toBe(0);
75
+ });
76
+ });
77
+
78
+ /*
79
+ * The third independent review: a repeat was handed the FIRST toast's
80
+ * handle and timer. A repeat asking `timeout: 0` was dismissed on the
81
+ * first one's timer, and closing one caller's handle closed the toast
82
+ * another caller was still showing.
83
+ */
84
+ describe("a repeat is its own caller", () => {
85
+
86
+ beforeEach(() => {
87
+ vi.useFakeTimers();
88
+ });
89
+ afterEach(() => {
90
+ vi.useRealTimers();
91
+ });
92
+
93
+ test("a repeat asking timeout 0 is not dismissed on the first one's timer", () => {
94
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
95
+ const sticky = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
96
+ vi.advanceTimersByTime(5000);
97
+ expect(toasts().length).toBe(1);
98
+ sticky.close();
99
+ expect(toasts().length).toBe(0);
100
+ });
101
+
102
+ test("closing the first caller's handle leaves the repeat's toast up", () => {
103
+ const first = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
104
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 3000});
105
+ first.close();
106
+ expect(toasts().length).toBe(1);
107
+ vi.advanceTimersByTime(3000);
62
108
  expect(toasts().length).toBe(0);
63
109
  });
110
+
111
+ test("the longest time wins, as a restart did", () => {
112
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
113
+ vi.advanceTimersByTime(800);
114
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 1000});
115
+ vi.advanceTimersByTime(500);
116
+ expect(toasts().length).toBe(1);
117
+ vi.advanceTimersByTime(500);
118
+ expect(toasts().length).toBe(0);
119
+ });
120
+
121
+ test("the operator's close takes it down for every caller", () => {
122
+ const first = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
123
+ const again = yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
124
+ toasts()[0].querySelector(".TOAST_CLOSE").click();
125
+ expect(toasts().length).toBe(0);
126
+ first.close();
127
+ again.close();
128
+ yui_shell_show_error(shell, "the connection dropped", {timeout: 0});
129
+ expect(toasts().length).toBe(1);
130
+ });
131
+ });
132
+
133
+ describe("every close control is named", () => {
134
+
135
+ function named($b)
136
+ {
137
+ /* The text itself is whatever the app's i18next answers (none
138
+ * here); what is checked is that both are THERE and carry the
139
+ * key a language change re-translates them by. */
140
+ return {
141
+ title: $b.getAttribute("title") !== null,
142
+ title_key: $b.getAttribute("data-i18n-title"),
143
+ label: $b.getAttribute("aria-label") !== null,
144
+ label_key: $b.getAttribute("data-i18n-aria-label"),
145
+ };
146
+ }
147
+
148
+ const NAMED = {title: true, title_key: "close", label: true, label_key: "close"};
149
+
150
+ test("the toast's", () => {
151
+ yui_shell_show_error(shell, "the connection dropped");
152
+ expect(named(toasts()[0].querySelector(".TOAST_CLOSE"))).toEqual(NAMED);
153
+ });
154
+
155
+ test("a modal's, dialog or plain, and a confirmation's", () => {
156
+ shell.priv.layers.modal = document.createElement("div");
157
+ yui_shell_show_modal(shell, document.createElement("div"), {dialog: true, title: "x"});
158
+ yui_shell_show_modal(shell, document.createElement("div"), {});
159
+ yui_shell_confirm_danger(shell, "delete this column?");
160
+ const $closes = shell.priv.layers.modal.querySelectorAll(
161
+ ".MODAL_CLOSE, .CONFIRM_CLOSE");
162
+ expect($closes.length).toBe(3);
163
+ for(const $b of $closes) {
164
+ expect(named($b)).toEqual(NAMED);
165
+ }
166
+ });
64
167
  });