@k2b/ui 0.11.0 → 0.12.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.
Files changed (69) hide show
  1. package/dist/browser/content/FileView.js +203 -136
  2. package/dist/browser/content/FileView.js.map +1 -1
  3. package/dist/browser/content/markdown-title.js +52 -0
  4. package/dist/browser/content/markdown-title.js.map +1 -0
  5. package/dist/browser/feedback/index.js.map +1 -1
  6. package/dist/browser/feedback/prompts.js +18 -19
  7. package/dist/browser/feedback/prompts.js.map +1 -1
  8. package/dist/browser/feedback/toast.js +363 -122
  9. package/dist/browser/feedback/toast.js.map +1 -1
  10. package/dist/browser/inputs/DatePicker.js +263 -222
  11. package/dist/browser/inputs/DatePicker.js.map +1 -1
  12. package/dist/browser/inputs/MultiSelectInput.js +139 -105
  13. package/dist/browser/inputs/MultiSelectInput.js.map +1 -1
  14. package/dist/browser/inputs/NumberInput.js +209 -122
  15. package/dist/browser/inputs/NumberInput.js.map +1 -1
  16. package/dist/browser/inputs/Select.js +184 -92
  17. package/dist/browser/inputs/Select.js.map +1 -1
  18. package/dist/browser/inputs/index.js.map +1 -1
  19. package/dist/browser/intl/messages.js +4 -0
  20. package/dist/browser/intl/messages.js.map +1 -1
  21. package/dist/browser/layout/DetailPanel.js +2 -1
  22. package/dist/browser/layout/DetailPanel.js.map +1 -1
  23. package/dist/browser/layout/Navigation.js +1 -0
  24. package/dist/browser/layout/Navigation.js.map +1 -1
  25. package/dist/browser/layout/PanelDialog.js +1 -0
  26. package/dist/browser/layout/PanelDialog.js.map +1 -1
  27. package/dist/browser/layout/Settings.js +2 -2
  28. package/dist/browser/layout/Settings.js.map +1 -1
  29. package/dist/ssr/content/FileView.js +141 -81
  30. package/dist/ssr/content/FileView.js.map +1 -1
  31. package/dist/ssr/content/markdown-title.js +52 -0
  32. package/dist/ssr/content/markdown-title.js.map +1 -0
  33. package/dist/ssr/feedback/index.js.map +1 -1
  34. package/dist/ssr/feedback/prompts.js +1 -1
  35. package/dist/ssr/feedback/prompts.js.map +1 -1
  36. package/dist/ssr/feedback/toast.js +363 -122
  37. package/dist/ssr/feedback/toast.js.map +1 -1
  38. package/dist/ssr/inputs/DatePicker.js +78 -52
  39. package/dist/ssr/inputs/DatePicker.js.map +1 -1
  40. package/dist/ssr/inputs/MultiSelectInput.js +52 -32
  41. package/dist/ssr/inputs/MultiSelectInput.js.map +1 -1
  42. package/dist/ssr/inputs/NumberInput.js +93 -40
  43. package/dist/ssr/inputs/NumberInput.js.map +1 -1
  44. package/dist/ssr/inputs/Select.js +108 -29
  45. package/dist/ssr/inputs/Select.js.map +1 -1
  46. package/dist/ssr/inputs/index.js.map +1 -1
  47. package/dist/ssr/intl/messages.js +4 -0
  48. package/dist/ssr/intl/messages.js.map +1 -1
  49. package/dist/ssr/layout/DetailPanel.js +2 -1
  50. package/dist/ssr/layout/DetailPanel.js.map +1 -1
  51. package/dist/ssr/layout/Navigation.js +1 -0
  52. package/dist/ssr/layout/Navigation.js.map +1 -1
  53. package/dist/ssr/layout/PanelDialog.js +1 -0
  54. package/dist/ssr/layout/PanelDialog.js.map +1 -1
  55. package/dist/ssr/layout/Settings.js +2 -2
  56. package/dist/ssr/layout/Settings.js.map +1 -1
  57. package/dist/styles.css +1 -1
  58. package/dist/types/content/FileView.d.ts +11 -1
  59. package/dist/types/content/markdown-title.d.ts +9 -0
  60. package/dist/types/feedback/index.d.ts +1 -1
  61. package/dist/types/feedback/toast.d.ts +7 -0
  62. package/dist/types/inputs/DatePicker.d.ts +11 -2
  63. package/dist/types/inputs/MultiSelectInput.d.ts +5 -1
  64. package/dist/types/inputs/NumberInput.d.ts +14 -0
  65. package/dist/types/inputs/Select.d.ts +10 -0
  66. package/dist/types/inputs/index.d.ts +1 -1
  67. package/dist/types/intl/messages.d.ts +2 -0
  68. package/dist/types/layout/Settings.d.ts +2 -2
  69. package/package.json +1 -1
@@ -1,30 +1,47 @@
1
1
  import { getK2bPortalRoot } from "../internal/portal";
2
2
  import { resolveUiMessages } from "../intl/messages";
3
- const DEFAULT_DURATION_MS = 3000;
4
- const MAX_VISIBLE_TOASTS = 5;
3
+ /** Visible toasts on a wide viewport; the rail's narrow layout shows fewer. */
4
+ const MAX_VISIBLE_TOASTS = 3;
5
+ const MAX_VISIBLE_TOASTS_NARROW = 2;
6
+ /** The same media query that moves the rail to the top edge in the stylesheet. */
7
+ const NARROW_RAIL_QUERY = "(max-width: 47.999rem), (max-height: 29.999rem)";
5
8
  const ANIMATION_MS = 200;
9
+ /** Reading time: a short message stays 4 s, longer text about 1 s more per 30 characters, up to 12 s. */
10
+ const READ_BASE_MS = 4_000;
11
+ const READ_FREE_CHARACTERS = 40;
12
+ const READ_MS_PER_CHARACTER = 35;
13
+ const READ_MAX_MS = 12_000;
14
+ /** Errors and toasts with an action stay long enough to reach them with a keyboard or a screen magnifier. */
15
+ const REACHABLE_MS = 8_000;
16
+ /** An error longer than this stays until it is closed, so it can be read and copied. */
17
+ const STICKY_ERROR_CHARACTERS = 120;
18
+ /** A live region needs a moment between being found and being written to. */
19
+ const ANNOUNCE_DELAY_MS = 100;
20
+ const ANNOUNCEMENT_LIFETIME_MS = 7_000;
6
21
  export const K2B_TOAST_CONTAINER_ID = "k2b-ui-toast-container";
7
22
  const CONTAINER_ATTRIBUTE = "data-k2b-toast-container";
23
+ const LIVE_ATTRIBUTE = "data-k2b-toast-live";
8
24
  const VARIANT_STYLES = {
9
25
  default: {
10
26
  tone: "info",
11
- iconClass: "ti-info-circle",
12
- titleKey: "info"
27
+ iconClass: "ti-info-circle"
13
28
  },
14
29
  success: {
15
30
  tone: "success",
16
- iconClass: "ti-check",
17
- titleKey: "success"
31
+ iconClass: "ti-circle-check"
18
32
  },
19
33
  error: {
20
34
  tone: "danger",
21
- iconClass: "ti-x",
22
- titleKey: "error"
35
+ iconClass: "ti-alert-circle"
23
36
  }
24
37
  };
25
38
 
26
- /** Default titles follow the document locale, like the other imperative `@k2b/ui` surfaces. */
27
- const defaultTitle = variant => resolveUiMessages()[VARIANT_STYLES[variant].titleKey];
39
+ /** How long a toast stays when the caller passes no duration. */
40
+ const defaultDuration = (variant, text, hasAction) => {
41
+ if (variant === "error" && text.length > STICKY_ERROR_CHARACTERS) return 0;
42
+ const reading = Math.min(READ_MAX_MS, READ_BASE_MS + Math.max(0, text.length - READ_FREE_CHARACTERS) * READ_MS_PER_CHARACTER);
43
+ return variant === "error" || hasAction ? Math.max(REACHABLE_MS, reading) : reading;
44
+ };
28
45
  const normalizedIconClass = iconClass => {
29
46
  const tokens = iconClass.split(/\s+/).filter(Boolean).filter(token => token !== "ti");
30
47
  return ["ti", ...tokens].join(" ");
@@ -32,35 +49,133 @@ const normalizedIconClass = iconClass => {
32
49
  const applyIconClass = (icon, iconClass) => {
33
50
  icon.className = normalizedIconClass(iconClass);
34
51
  };
35
- const applyLiveRegion = (element, variant) => {
36
- const isError = variant === "error";
37
- element.setAttribute("role", isError ? "alert" : "status");
38
- element.setAttribute("aria-live", isError ? "assertive" : "polite");
39
- element.setAttribute("aria-atomic", "true");
40
- };
41
52
  const liveToasts = new Set();
53
+
54
+ /**
55
+ * Cards under the pointer or holding keyboard focus. While the rail holds any, no toast times out, so nothing moves
56
+ * under the pointer or out from under focus because a neighbour expired.
57
+ */
58
+ const pointerHolds = new Set();
59
+ const focusHolds = new Set();
60
+ const railHeld = () => pointerHolds.size + focusHolds.size > 0;
61
+
62
+ /**
63
+ * Firefox and WebKit fire no focusout when a focused element leaves the document, and WebKit no pointerleave when
64
+ * the hovered element inside a card is removed, as when custom content replaces a button or the rail's scope
65
+ * unmounts. While anything holds the rail, a change to the document or the pointer moving onto something else
66
+ * drops the holds that no longer apply, so one stale hold never freezes the rail.
67
+ */
68
+ const releaseStaleHolds = pointerTarget => {
69
+ for (const card of Array.from(pointerHolds)) if (!card.isConnected || pointerTarget !== undefined && !card.contains(pointerTarget)) setHold(pointerHolds, card, false);
70
+ for (const card of Array.from(focusHolds)) if (!card.isConnected || !card.contains(card.ownerDocument.activeElement)) setHold(focusHolds, card, false);
71
+ };
72
+ const watchStaleHolds = doc => {
73
+ const observer = new MutationObserver(() => releaseStaleHolds());
74
+ observer.observe(doc, {
75
+ childList: true,
76
+ subtree: true
77
+ });
78
+ const onPointerOver = event => releaseStaleHolds(event.target instanceof Node ? event.target : null);
79
+ doc.addEventListener("pointerover", onPointerOver, true);
80
+ return () => {
81
+ observer.disconnect();
82
+ doc.removeEventListener("pointerover", onPointerOver, true);
83
+ };
84
+ };
85
+ let unwatchStaleHolds = null;
86
+ function setHold(holds, card, held) {
87
+ const wasHeld = railHeld();
88
+ if (held) holds.add(card);else holds.delete(card);
89
+ if (railHeld() !== wasHeld) {
90
+ for (const item of Array.from(liveToasts)) (wasHeld ? item.resume : item.pause)();
91
+ unwatchStaleHolds?.();
92
+ unwatchStaleHolds = wasHeld ? null : watchStaleHolds(card.ownerDocument);
93
+ }
94
+ // A released card may be the one the rail limit had to wait for.
95
+ if (!held) enforceCap();
96
+ }
97
+ const VISUALLY_HIDDEN = "position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;";
98
+
99
+ /**
100
+ * Two persistent, empty live regions beside the rail: polite for every toast, assertive for errors. A region that
101
+ * already exists when its text arrives is announced reliably, unlike one inserted with its text already inside.
102
+ * They sit outside the rail because the rail moves into a fresh top-layer element for every toast.
103
+ */
104
+ const ensureLiveRegions = root => {
105
+ const existing = Array.from(root.children).find(child => child.hasAttribute(LIVE_ATTRIBUTE));
106
+ if (existing instanceof HTMLElement) return existing;
107
+ const live = document.createElement("div");
108
+ live.setAttribute(LIVE_ATTRIBUTE, "");
109
+ live.style.cssText = VISUALLY_HIDDEN;
110
+ for (const assertive of [false, true]) {
111
+ const region = document.createElement("div");
112
+ region.setAttribute("role", assertive ? "alert" : "status");
113
+ region.setAttribute("aria-live", assertive ? "assertive" : "polite");
114
+ // Both roles are atomic by default, which would read every line still in the region again with each new one.
115
+ region.setAttribute("aria-atomic", "false");
116
+ region.dataset.politeness = assertive ? "assertive" : "polite";
117
+ live.appendChild(region);
118
+ }
119
+ root.appendChild(live);
120
+ return live;
121
+ };
122
+
123
+ /** Each announcement is its own line, so a burst of toasts or a repeated message is still read. */
124
+ const announce = (root, text, assertive) => {
125
+ if (!root || !text) return;
126
+ const region = ensureLiveRegions(root).querySelector(`[data-politeness="${assertive ? "assertive" : "polite"}"]`);
127
+ if (!region) return;
128
+ const line = document.createElement("div");
129
+ line.textContent = text;
130
+ setTimeout(() => {
131
+ region.appendChild(line);
132
+ setTimeout(() => line.remove(), ANNOUNCEMENT_LIFETIME_MS);
133
+ }, ANNOUNCE_DELAY_MS);
134
+ };
42
135
  const ensureContainer = () => {
43
136
  if (typeof document === "undefined") return null;
44
137
  const root = getK2bPortalRoot();
138
+ ensureLiveRegions(root);
45
139
  let container = root.querySelector(`[${CONTAINER_ATTRIBUTE}]`);
46
140
  if (container) return container;
47
141
  container = document.createElement("div");
48
142
  container.id = K2B_TOAST_CONTAINER_ID;
49
143
  container.setAttribute(CONTAINER_ATTRIBUTE, "");
50
144
  container.setAttribute("popover", "manual");
145
+ container.setAttribute("role", "region");
146
+ container.setAttribute("aria-label", resolveUiMessages().notifications);
51
147
  // Keep the source rail geometry inline: it must defeat UA popover defaults
52
148
  // even when a consumer has not loaded the optional package stylesheet yet.
53
- // The stylesheet only moves the rail to the top edge on narrow viewports
54
- // through the two edge variables.
55
- container.style.cssText = "position:fixed;left:auto;right:env(safe-area-inset-right,0px);" + "top:var(--k2b-toast-rail-top,auto);bottom:var(--k2b-toast-rail-bottom,env(safe-area-inset-bottom,0px));" + "z-index:50;box-sizing:border-box;display:flex;flex-direction:column;gap:0.5rem;" + "width:min(22rem,calc(100vw - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px)));" + "height:auto;max-width:100vw;" + "max-height:calc(100dvh - env(safe-area-inset-top,0px) - env(safe-area-inset-bottom,0px));" + "margin:0;padding:1rem;border:0;background:transparent;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;" + "pointer-events:none;";
149
+ // The stylesheet only moves the rail to the top edge, and to the full width
150
+ // of a phone, through the rail variables.
151
+ container.style.cssText = "position:fixed;left:var(--k2b-toast-rail-left,auto);right:env(safe-area-inset-right,0px);" + "top:var(--k2b-toast-rail-top,auto);bottom:var(--k2b-toast-rail-bottom,env(safe-area-inset-bottom,0px));" + "z-index:50;box-sizing:border-box;display:flex;flex-direction:var(--k2b-toast-rail-direction,column);" + "width:var(--k2b-toast-rail-width,min(24rem,calc(100vw - env(safe-area-inset-left,0px) - env(safe-area-inset-right,0px))));" + "height:auto;max-width:100vw;" + "max-height:calc(100dvh - env(safe-area-inset-top,0px) - var(--k2b-toast-offset-top,0px) - env(safe-area-inset-bottom,0px));" + "margin:0;padding:var(--k2b-toast-rail-padding,0.75rem 1rem);border:0;background:transparent;overflow-x:hidden;overflow-y:auto;overscroll-behavior:contain;" + "pointer-events:none;";
56
152
  root.appendChild(container);
57
153
  return container;
58
154
  };
155
+
156
+ /** Moving nodes resets their scroll offsets and drops focus; a custom slot can hold a scrolled list or a focused control. */
157
+ const captureViewState = container => {
158
+ const focused = document.activeElement instanceof HTMLElement && container.contains(document.activeElement) ? document.activeElement : null;
159
+ const scrolled = Array.from(container.querySelectorAll("*")).filter(element => element.scrollTop || element.scrollLeft).map(element => [element, element.scrollTop, element.scrollLeft]);
160
+ const railTop = container.scrollTop;
161
+ return rail => {
162
+ rail.scrollTop = railTop;
163
+ for (const [element, top, left] of scrolled) {
164
+ element.scrollTop = top;
165
+ element.scrollLeft = left;
166
+ }
167
+ if (focused?.isConnected && rail.contains(focused)) focused.focus({
168
+ preventScroll: true
169
+ });
170
+ };
171
+ };
59
172
  const promoteToTopLayer = container => {
60
173
  if (typeof container.showPopover !== "function" || !container.isConnected) return container;
61
174
  let active = container;
175
+ let restore = null;
62
176
  try {
63
177
  if (container.matches(":popover-open") || document.querySelector("dialog:modal")) {
178
+ restore = captureViewState(container);
64
179
  const next = container.cloneNode(false);
65
180
  while (container.firstChild) next.appendChild(container.firstChild);
66
181
  const root = container.parentElement ?? getK2bPortalRoot();
@@ -72,6 +187,8 @@ const promoteToTopLayer = container => {
72
187
  } catch {
73
188
  active.removeAttribute("popover");
74
189
  }
190
+ // Offsets apply only once the rail is rendered again, so after it is shown.
191
+ restore?.(active);
75
192
  return active;
76
193
  };
77
194
 
@@ -90,17 +207,66 @@ const hideEmptyContainers = () => {
90
207
  }
91
208
  }
92
209
  };
93
- const renderLead = (lead, variant, iconClassOverride) => {
94
- const style = VARIANT_STYLES[variant];
95
- lead.replaceChildren();
96
- lead.className = "k2b-toast__icon";
97
- lead.dataset.tone = style.tone;
98
- const icon = document.createElement("i");
99
- applyIconClass(icon, iconClassOverride ?? style.iconClass);
100
- icon.setAttribute("aria-hidden", "true");
101
- lead.appendChild(icon);
102
- return icon;
210
+
211
+ /**
212
+ * Every toast and custom slot sits in a slot that grows from and collapses to zero height, so its neighbours glide
213
+ * instead of jumping when it arrives or leaves. The card carries the chrome and `data-open` / `data-closing`.
214
+ */
215
+
216
+ const railItem = card => {
217
+ card.addEventListener("pointerenter", () => setHold(pointerHolds, card, true));
218
+ card.addEventListener("pointerleave", () => setHold(pointerHolds, card, false));
219
+ card.addEventListener("focusin", () => setHold(focusHolds, card, true));
220
+ card.addEventListener("focusout", event => {
221
+ if (!card.contains(event.relatedTarget)) setHold(focusHolds, card, false);
222
+ });
223
+ const slot = document.createElement("div");
224
+ slot.className = "k2b-toast-slot";
225
+ slot.dataset.state = "entering";
226
+ const clip = document.createElement("div");
227
+ clip.className = "k2b-toast-slot__clip";
228
+ clip.appendChild(card);
229
+ slot.appendChild(clip);
230
+ return {
231
+ slot,
232
+ open: () => {
233
+ // The entering state must be laid out once before it changes, or nothing transitions.
234
+ slot.getBoundingClientRect();
235
+ requestAnimationFrame(() => {
236
+ if (card.dataset.closing) return;
237
+ slot.dataset.state = "open";
238
+ card.dataset.open = "true";
239
+ });
240
+ },
241
+ close: onRemoved => {
242
+ slot.dataset.state = "closing";
243
+ card.dataset.closing = "true";
244
+ // A card that leaves under the pointer or with focus no longer holds the rail.
245
+ if (pointerHolds.has(card)) setHold(pointerHolds, card, false);
246
+ if (focusHolds.has(card)) setHold(focusHolds, card, false);
247
+ setTimeout(() => {
248
+ slot.remove();
249
+ hideEmptyContainers();
250
+ onRemoved?.();
251
+ }, ANIMATION_MS);
252
+ }
253
+ };
103
254
  };
255
+
256
+ /**
257
+ * The rail is full: older timed confirmations leave first, then older errors and sticky toasts. Running progress, the
258
+ * newest toast, and a toast under the pointer or with focus stay; the rail waits until they are released.
259
+ */
260
+ function enforceCap() {
261
+ const narrow = typeof matchMedia === "function" && matchMedia(NARROW_RAIL_QUERY).matches;
262
+ const cap = narrow ? MAX_VISIBLE_TOASTS_NARROW : MAX_VISIBLE_TOASTS;
263
+ while (liveToasts.size > cap) {
264
+ const older = Array.from(liveToasts).slice(0, -1).filter(item => !item.held());
265
+ const victim = older.find(item => !item.keep()) ?? older.find(item => !item.progress());
266
+ if (!victim) return;
267
+ victim.dismiss();
268
+ }
269
+ }
104
270
  const showToast = (description, options) => {
105
271
  const initialContainer = ensureContainer();
106
272
  if (!initialContainer) {
@@ -110,40 +276,70 @@ const showToast = (description, options) => {
110
276
  update: noop
111
277
  };
112
278
  }
279
+ const messages = resolveUiMessages();
280
+ const doc = initialContainer.ownerDocument;
281
+ // Focus returns here when a focused toast closes and no other toast can take it.
282
+ const opener = doc.activeElement instanceof HTMLElement && !doc.activeElement.closest(`[${CONTAINER_ATTRIBUTE}]`) ? doc.activeElement : null;
113
283
  let dismissed = false;
114
284
  let dismissTimer = null;
115
285
  let currentVariant = options?.variant ?? "default";
116
286
  let currentProgress = options?.progress ?? null;
117
- let currentDuration = options?.duration ?? DEFAULT_DURATION_MS;
118
- let remainingDuration = currentDuration;
287
+ let currentAction = options?.action ?? null;
288
+ let explicitDuration = options?.duration;
289
+ let remainingDuration = 0;
119
290
  let timerStartedAt = 0;
120
- let pausedByPointer = false;
121
- let pausedByFocus = false;
122
- const toastElement = document.createElement("div");
291
+ let lastAnnouncement = "";
292
+ const toastElement = doc.createElement("div");
123
293
  toastElement.className = "k2b-toast";
124
294
  toastElement.dataset.tone = VARIANT_STYLES[currentVariant].tone;
125
295
  toastElement.dataset.k2bToast = "";
126
- const leadElement = document.createElement("div");
127
- let leadIconElement = renderLead(leadElement, currentVariant, options?.iconClass);
128
- const contentElement = document.createElement("div");
296
+ const leadElement = doc.createElement("div");
297
+ leadElement.className = "k2b-toast__icon";
298
+ leadElement.setAttribute("aria-hidden", "true");
299
+ const leadIconElement = doc.createElement("i");
300
+ leadElement.appendChild(leadIconElement);
301
+ const renderLead = iconClassOverride => {
302
+ leadElement.dataset.tone = VARIANT_STYLES[currentVariant].tone;
303
+ applyIconClass(leadIconElement, iconClassOverride ?? VARIANT_STYLES[currentVariant].iconClass);
304
+ };
305
+ renderLead(options?.iconClass);
306
+ const contentElement = doc.createElement("div");
129
307
  contentElement.className = "k2b-toast__content";
130
- applyLiveRegion(contentElement, currentVariant);
131
- const titleElement = document.createElement("div");
308
+ const titleElement = doc.createElement("div");
132
309
  titleElement.className = "k2b-toast__title";
133
- titleElement.textContent = options?.title ?? defaultTitle(currentVariant);
134
- const descriptionElement = document.createElement("div");
310
+ const renderTitle = title => {
311
+ titleElement.textContent = title ?? "";
312
+ titleElement.hidden = !title;
313
+ };
314
+ renderTitle(options?.title);
315
+ const descriptionElement = doc.createElement("div");
135
316
  descriptionElement.className = "k2b-toast__description";
136
317
  descriptionElement.textContent = description;
137
318
  contentElement.append(titleElement, descriptionElement);
138
- const closeButton = document.createElement("button");
319
+ const closeButton = doc.createElement("button");
139
320
  closeButton.type = "button";
140
321
  closeButton.className = "k2b-toast__close";
141
- closeButton.setAttribute("aria-label", options?.dismissLabel ?? resolveUiMessages().dismissNotification);
142
- const closeIcon = document.createElement("i");
322
+ closeButton.setAttribute("aria-label", options?.dismissLabel ?? messages.dismissNotification);
323
+ const closeIcon = doc.createElement("i");
143
324
  closeIcon.className = "ti ti-x";
144
325
  closeIcon.setAttribute("aria-hidden", "true");
145
326
  closeButton.appendChild(closeIcon);
327
+
328
+ // Inside the content, so a titled progress toast can place the bar between its title and its summary line.
329
+ const progressElement = doc.createElement("progress");
330
+ progressElement.className = "k2b-toast__progress";
331
+ progressElement.max = 1;
332
+ contentElement.appendChild(progressElement);
146
333
  toastElement.append(leadElement, contentElement, closeButton);
334
+ const item = railItem(toastElement);
335
+ const text = () => [titleElement.textContent, descriptionElement.textContent].filter(Boolean).join(". ");
336
+ const effectiveDuration = () => explicitDuration ?? defaultDuration(currentVariant, text(), currentAction !== null);
337
+ /** The error word is read before the message; the action is left out. */
338
+ const spoken = () => currentVariant === "error" ? `${resolveUiMessages().error}: ${text()}` : text();
339
+ const say = () => {
340
+ lastAnnouncement = spoken();
341
+ announce(item.slot.parentElement?.parentElement ?? null, lastAnnouncement, currentVariant === "error");
342
+ };
147
343
  const clearDismissTimer = () => {
148
344
  if (dismissTimer === null) return;
149
345
  clearTimeout(dismissTimer);
@@ -155,132 +351,176 @@ const showToast = (description, options) => {
155
351
  clearDismissTimer();
156
352
  };
157
353
  const resumeDismissTimer = () => {
158
- if (dismissed || currentProgress !== null || currentDuration === 0 || remainingDuration <= 0 || pausedByPointer || pausedByFocus) return;
354
+ if (dismissed || currentProgress !== null || remainingDuration <= 0 || railHeld() || doc.hidden) return;
159
355
  clearDismissTimer();
160
356
  timerStartedAt = Date.now();
161
357
  dismissTimer = setTimeout(() => dismiss(), remainingDuration);
162
358
  };
163
- const resetDismissTimer = duration => {
359
+ const resetDismissTimer = () => {
164
360
  clearDismissTimer();
165
- currentDuration = duration;
166
- remainingDuration = duration;
361
+ remainingDuration = effectiveDuration();
167
362
  resumeDismissTimer();
168
363
  };
364
+ const onVisibilityChange = () => doc.hidden ? pauseDismissTimer() : resumeDismissTimer();
365
+
366
+ /** The close button of the nearest open toast in one direction, past neighbours that are still closing. */
367
+ const nearestOpenClose = step => {
368
+ for (let slot = step(item.slot); slot; slot = step(slot)) {
369
+ const button = slot.querySelector(".k2b-toast:not([data-closing]) .k2b-toast__close");
370
+ if (button) return button;
371
+ }
372
+ return null;
373
+ };
374
+
375
+ /** A focused toast hands focus to its neighbour, or back to where the user was, instead of dropping it on the page. */
376
+ const moveFocusAway = () => {
377
+ if (!toastElement.contains(doc.activeElement)) return;
378
+ const next = nearestOpenClose(slot => slot.nextElementSibling) ?? nearestOpenClose(slot => slot.previousElementSibling);
379
+ const target = next ?? (opener?.isConnected ? opener : null);
380
+ if (target) target.focus({
381
+ preventScroll: true
382
+ });else doc.activeElement?.blur();
383
+ };
169
384
  const dismiss = () => {
170
385
  if (dismissed) return;
171
386
  dismissed = true;
172
387
  clearDismissTimer();
173
- liveToasts.delete(handle);
174
- toastElement.dataset.closing = "true";
175
- setTimeout(() => {
176
- toastElement.remove();
177
- hideEmptyContainers();
178
- }, ANIMATION_MS);
388
+ liveToasts.delete(live);
389
+ doc.removeEventListener("visibilitychange", onVisibilityChange);
390
+ moveFocusAway();
391
+ item.close();
179
392
  };
180
- const progressElement = document.createElement("progress");
181
- progressElement.className = "k2b-toast__progress";
182
- progressElement.max = 1;
183
393
  const renderProgress = () => {
184
394
  progressElement.hidden = currentProgress === null;
185
- progressElement.setAttribute("aria-label", titleElement.textContent || descriptionElement.textContent || "");
395
+ toastElement.dataset.progress = String(currentProgress !== null);
396
+ const title = titleElement.textContent;
397
+ const summary = descriptionElement.textContent ?? "";
398
+ progressElement.setAttribute("aria-label", title || summary);
399
+ // With a title, the message is the summary line under the bar ("6 of 12 files"), read instead of a bare percentage.
400
+ if (typeof currentProgress === "number" && title && summary) progressElement.setAttribute("aria-valuetext", summary);else progressElement.removeAttribute("aria-valuetext");
186
401
  if (typeof currentProgress === "number") progressElement.value = Number.isFinite(currentProgress) ? Math.max(0, Math.min(1, currentProgress)) : 0;else progressElement.removeAttribute("value");
187
402
  };
188
- contentElement.append(progressElement);
189
403
  renderProgress();
190
404
  let actionElement = null;
191
- const renderAction = action => {
192
- actionElement?.remove();
193
- actionElement = null;
194
- if (!action) return;
405
+ const createAction = action => {
406
+ let element;
195
407
  if ("href" in action) {
196
- const link = document.createElement("a");
197
- link.href = action.href;
198
- actionElement = link;
408
+ element = doc.createElement("a");
409
+ element.href = action.href;
199
410
  } else {
200
- const button = document.createElement("button");
201
- button.type = "button";
202
- actionElement = button;
411
+ element = doc.createElement("button");
412
+ element.type = "button";
203
413
  }
204
- actionElement.className = "k2b-toast__action";
205
- actionElement.textContent = action.label;
206
- actionElement.addEventListener("click", event => {
207
- event.stopPropagation();
414
+ element.className = "k2b-toast__action";
415
+ element.textContent = action.label;
416
+ element.addEventListener("click", () => {
208
417
  if ("onClick" in action) action.onClick();else dismiss();
209
418
  });
210
- contentElement.appendChild(actionElement);
419
+ return element;
420
+ };
421
+ const renderAction = action => {
422
+ const previous = actionElement;
423
+ currentAction = action ?? null;
424
+ actionElement = action ? createAction(action) : null;
425
+ if (actionElement) contentElement.appendChild(actionElement);
426
+ // The keyboard stays in the toast, on the new action or the close button, instead of falling to the page.
427
+ if (previous?.contains(doc.activeElement)) (actionElement ?? closeButton).focus({
428
+ preventScroll: true
429
+ });
430
+ previous?.remove();
211
431
  };
212
432
  renderAction(options?.action);
213
433
  const update = (nextDescription, nextOptions) => {
214
434
  if (dismissed) return;
435
+ const has = key => nextOptions !== undefined && Object.prototype.hasOwnProperty.call(nextOptions, key);
436
+ const previousProgress = currentProgress;
215
437
  descriptionElement.textContent = nextDescription;
216
438
  const variantChanged = nextOptions?.variant !== undefined && nextOptions.variant !== currentVariant;
217
439
  if (variantChanged) {
218
440
  currentVariant = nextOptions.variant;
219
441
  toastElement.dataset.tone = VARIANT_STYLES[currentVariant].tone;
220
- leadIconElement = renderLead(leadElement, currentVariant, nextOptions.iconClass);
221
- applyLiveRegion(contentElement, currentVariant);
442
+ renderLead(nextOptions.iconClass);
222
443
  } else if (nextOptions?.iconClass !== undefined) {
223
444
  applyIconClass(leadIconElement, nextOptions.iconClass);
224
445
  }
225
- if (nextOptions && Object.prototype.hasOwnProperty.call(nextOptions, "title")) {
226
- titleElement.textContent = nextOptions.title ?? "";
227
- } else if (variantChanged) {
228
- titleElement.textContent = defaultTitle(currentVariant);
229
- }
230
- if (nextOptions && Object.prototype.hasOwnProperty.call(nextOptions, "action")) renderAction(nextOptions.action);
231
- if (nextOptions && Object.prototype.hasOwnProperty.call(nextOptions, "duration")) {
232
- currentDuration = nextOptions.duration ?? DEFAULT_DURATION_MS;
233
- }
234
- if (nextOptions && Object.prototype.hasOwnProperty.call(nextOptions, "progress")) currentProgress = nextOptions.progress ?? null;
446
+ if (has("title")) renderTitle(nextOptions.title);
447
+ if (has("action")) renderAction(nextOptions.action);
448
+ if (has("duration")) explicitDuration = nextOptions.duration;
449
+ if (has("progress")) currentProgress = nextOptions.progress ?? null;
235
450
  if (nextOptions?.dismissLabel) closeButton.setAttribute("aria-label", nextOptions.dismissLabel);
236
451
  renderProgress();
237
- resetDismissTimer(currentDuration);
452
+ resetDismissTimer();
453
+
454
+ // Progress is announced at its start, half way, and its end, never at every step, and only with something new to say.
455
+ // The comparison includes the error word, so a change to an error with the same text is still announced.
456
+ const ticking = previousProgress !== null && currentProgress !== null;
457
+ const halfway = typeof currentProgress === "number" && currentProgress >= 0.5 && !(typeof previousProgress === "number" && previousProgress >= 0.5);
458
+ if ((!ticking || halfway || variantChanged) && spoken() !== lastAnnouncement) say();
459
+ // Finished progress or a new duration can make this toast one the rail limit removes.
460
+ enforceCap();
238
461
  };
239
- toastElement.addEventListener("click", () => {
240
- if (currentProgress === null) dismiss();
241
- });
242
- closeButton.addEventListener("click", event => {
462
+ closeButton.addEventListener("click", () => dismiss());
463
+ toastElement.addEventListener("keydown", event => {
464
+ if (event.key !== "Escape" || event.defaultPrevented) return;
465
+ event.preventDefault();
243
466
  event.stopPropagation();
244
467
  dismiss();
245
468
  });
246
- toastElement.addEventListener("pointerenter", () => {
247
- pausedByPointer = true;
248
- pauseDismissTimer();
249
- });
250
- toastElement.addEventListener("pointerleave", () => {
251
- pausedByPointer = false;
252
- resumeDismissTimer();
253
- });
254
- toastElement.addEventListener("focusin", () => {
255
- pausedByFocus = true;
256
- pauseDismissTimer();
257
- });
258
- toastElement.addEventListener("focusout", event => {
259
- if (toastElement.contains(event.relatedTarget)) return;
260
- pausedByFocus = false;
261
- resumeDismissTimer();
262
- });
469
+ doc.addEventListener("visibilitychange", onVisibilityChange);
263
470
  const handle = {
264
471
  dismiss,
265
472
  update
266
473
  };
267
- liveToasts.add(handle);
268
- if (liveToasts.size > MAX_VISIBLE_TOASTS) liveToasts.values().next().value?.dismiss();
269
- promoteToTopLayer(initialContainer).appendChild(toastElement);
270
- requestAnimationFrame(() => {
271
- if (!dismissed) toastElement.dataset.open = "true";
272
- });
273
- resetDismissTimer(currentDuration);
474
+ const live = {
475
+ dismiss,
476
+ keep: () => currentVariant === "error" || currentProgress !== null || effectiveDuration() === 0,
477
+ progress: () => currentProgress !== null,
478
+ held: () => pointerHolds.has(toastElement) || focusHolds.has(toastElement),
479
+ pause: pauseDismissTimer,
480
+ resume: resumeDismissTimer
481
+ };
482
+ liveToasts.add(live);
483
+ promoteToTopLayer(initialContainer).appendChild(item.slot);
484
+ item.open();
485
+ say();
486
+ enforceCap();
487
+ resetDismissTimer();
274
488
  return handle;
275
489
  };
490
+
491
+ /*
492
+ * A custom slot shares the rail, chrome, and enter/leave motion with toasts, so it stacks beside them in the same
493
+ * corner instead of covering them. It has no timer, close button, or live region of its own, and neither the
494
+ * toast limit nor `dismissAll` removes it: the application that placed it decides when it goes.
495
+ */
496
+ const showCustom = content => {
497
+ const initialContainer = ensureContainer();
498
+ if (!initialContainer) return {
499
+ dismiss: () => {}
500
+ };
501
+ let dismissed = false;
502
+ const slotElement = document.createElement("div");
503
+ slotElement.className = "k2b-toast";
504
+ slotElement.dataset.k2bToast = "";
505
+ slotElement.dataset.custom = "true";
506
+ slotElement.append(content);
507
+ const item = railItem(slotElement);
508
+ const dismiss = () => {
509
+ if (dismissed) return;
510
+ dismissed = true;
511
+ item.close();
512
+ };
513
+ promoteToTopLayer(initialContainer).appendChild(item.slot);
514
+ item.open();
515
+ return {
516
+ dismiss
517
+ };
518
+ };
276
519
  export const isPointInsideToast = (x, y) => {
277
520
  if (typeof document === "undefined") return false;
278
- const containers = Array.from(document.querySelectorAll(`[${CONTAINER_ATTRIBUTE}]`));
279
- for (const container of containers) {
280
- for (const child of Array.from(container.children)) {
281
- const rect = child.getBoundingClientRect();
282
- if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return true;
283
- }
521
+ for (const card of Array.from(document.querySelectorAll(`[${CONTAINER_ATTRIBUTE}] [data-k2b-toast]`))) {
522
+ const rect = card.getBoundingClientRect();
523
+ if (x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom) return true;
284
524
  }
285
525
  return false;
286
526
  };
@@ -293,8 +533,9 @@ toastFn.error = (description, options) => showToast(description, {
293
533
  ...options,
294
534
  variant: "error"
295
535
  });
536
+ toastFn.custom = showCustom;
296
537
  toastFn.dismissAll = () => {
297
- for (const handle of Array.from(liveToasts)) handle.dismiss();
538
+ for (const item of Array.from(liveToasts)) item.dismiss();
298
539
  };
299
540
  export const toast = toastFn;
300
541
  //# sourceMappingURL=toast.js.map