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