@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.
- package/README.md +1 -1
- package/dist/browser/content/FileView.js +203 -136
- package/dist/browser/content/FileView.js.map +1 -1
- package/dist/browser/content/PdfPreview.js +5 -2
- package/dist/browser/content/PdfPreview.js.map +1 -1
- package/dist/browser/content/markdown-title.js +52 -0
- package/dist/browser/content/markdown-title.js.map +1 -0
- package/dist/browser/feedback/InstallGuide.js +267 -0
- package/dist/browser/feedback/InstallGuide.js.map +1 -0
- package/dist/browser/feedback/announce.js +50 -0
- package/dist/browser/feedback/announce.js.map +1 -0
- package/dist/browser/feedback/dialog-core.js +82 -1
- package/dist/browser/feedback/dialog-core.js.map +1 -1
- package/dist/browser/feedback/index.js +4 -0
- package/dist/browser/feedback/index.js.map +1 -1
- package/dist/browser/feedback/install.js +99 -0
- package/dist/browser/feedback/install.js.map +1 -0
- package/dist/browser/feedback/prompts.js +29 -20
- package/dist/browser/feedback/prompts.js.map +1 -1
- package/dist/browser/feedback/theme-color.js +15 -0
- package/dist/browser/feedback/theme-color.js.map +1 -0
- package/dist/browser/feedback/toast.js +329 -122
- package/dist/browser/feedback/toast.js.map +1 -1
- package/dist/browser/inputs/DatePicker.js +263 -222
- package/dist/browser/inputs/DatePicker.js.map +1 -1
- package/dist/browser/inputs/MultiSelectInput.js +139 -105
- package/dist/browser/inputs/MultiSelectInput.js.map +1 -1
- package/dist/browser/inputs/NumberInput.js +209 -122
- package/dist/browser/inputs/NumberInput.js.map +1 -1
- package/dist/browser/inputs/QrScanner.js +164 -0
- package/dist/browser/inputs/QrScanner.js.map +1 -0
- package/dist/browser/inputs/Select.js +184 -92
- package/dist/browser/inputs/Select.js.map +1 -1
- package/dist/browser/inputs/index.js +1 -0
- package/dist/browser/inputs/index.js.map +1 -1
- package/dist/browser/intl/messages.js +90 -2
- package/dist/browser/intl/messages.js.map +1 -1
- package/dist/browser/layout/DetailPanel.js +2 -1
- package/dist/browser/layout/DetailPanel.js.map +1 -1
- package/dist/browser/layout/Discussion.js +2 -1
- package/dist/browser/layout/Discussion.js.map +1 -1
- package/dist/browser/layout/MobileShell.js +242 -0
- package/dist/browser/layout/MobileShell.js.map +1 -0
- package/dist/browser/layout/Navigation.js +1 -0
- package/dist/browser/layout/Navigation.js.map +1 -1
- package/dist/browser/layout/PanelDialog.js +1 -0
- package/dist/browser/layout/PanelDialog.js.map +1 -1
- package/dist/browser/layout/Settings.js +2 -2
- package/dist/browser/layout/Settings.js.map +1 -1
- package/dist/browser/layout/TabBar.js +65 -0
- package/dist/browser/layout/TabBar.js.map +1 -0
- package/dist/browser/layout/index.js +2 -0
- package/dist/browser/layout/index.js.map +1 -1
- package/dist/browser/surfaces/NoticeCard.js +55 -60
- package/dist/browser/surfaces/NoticeCard.js.map +1 -1
- package/dist/browser/surfaces/index.js +1 -1
- package/dist/browser/surfaces/index.js.map +1 -1
- package/dist/ssr/content/FileView.js +141 -81
- package/dist/ssr/content/FileView.js.map +1 -1
- package/dist/ssr/content/PdfPreview.js +4 -2
- package/dist/ssr/content/PdfPreview.js.map +1 -1
- package/dist/ssr/content/markdown-title.js +52 -0
- package/dist/ssr/content/markdown-title.js.map +1 -0
- package/dist/ssr/feedback/InstallGuide.js +201 -0
- package/dist/ssr/feedback/InstallGuide.js.map +1 -0
- package/dist/ssr/feedback/announce.js +50 -0
- package/dist/ssr/feedback/announce.js.map +1 -0
- package/dist/ssr/feedback/dialog-core.js +82 -1
- package/dist/ssr/feedback/dialog-core.js.map +1 -1
- package/dist/ssr/feedback/index.js +4 -0
- package/dist/ssr/feedback/index.js.map +1 -1
- package/dist/ssr/feedback/install.js +99 -0
- package/dist/ssr/feedback/install.js.map +1 -0
- package/dist/ssr/feedback/prompts.js +12 -2
- package/dist/ssr/feedback/prompts.js.map +1 -1
- package/dist/ssr/feedback/theme-color.js +15 -0
- package/dist/ssr/feedback/theme-color.js.map +1 -0
- package/dist/ssr/feedback/toast.js +329 -122
- package/dist/ssr/feedback/toast.js.map +1 -1
- package/dist/ssr/inputs/DatePicker.js +78 -52
- package/dist/ssr/inputs/DatePicker.js.map +1 -1
- package/dist/ssr/inputs/MultiSelectInput.js +52 -32
- package/dist/ssr/inputs/MultiSelectInput.js.map +1 -1
- package/dist/ssr/inputs/NumberInput.js +93 -40
- package/dist/ssr/inputs/NumberInput.js.map +1 -1
- package/dist/ssr/inputs/QrScanner.js +128 -0
- package/dist/ssr/inputs/QrScanner.js.map +1 -0
- package/dist/ssr/inputs/Select.js +108 -29
- package/dist/ssr/inputs/Select.js.map +1 -1
- package/dist/ssr/inputs/index.js +1 -0
- package/dist/ssr/inputs/index.js.map +1 -1
- package/dist/ssr/intl/messages.js +90 -2
- package/dist/ssr/intl/messages.js.map +1 -1
- package/dist/ssr/layout/DetailPanel.js +2 -1
- package/dist/ssr/layout/DetailPanel.js.map +1 -1
- package/dist/ssr/layout/Discussion.js +2 -1
- package/dist/ssr/layout/Discussion.js.map +1 -1
- package/dist/ssr/layout/MobileShell.js +211 -0
- package/dist/ssr/layout/MobileShell.js.map +1 -0
- package/dist/ssr/layout/Navigation.js +1 -0
- package/dist/ssr/layout/Navigation.js.map +1 -1
- package/dist/ssr/layout/PanelDialog.js +1 -0
- package/dist/ssr/layout/PanelDialog.js.map +1 -1
- package/dist/ssr/layout/Settings.js +2 -2
- package/dist/ssr/layout/Settings.js.map +1 -1
- package/dist/ssr/layout/TabBar.js +24 -0
- package/dist/ssr/layout/TabBar.js.map +1 -0
- package/dist/ssr/layout/index.js +2 -0
- package/dist/ssr/layout/index.js.map +1 -1
- package/dist/ssr/surfaces/NoticeCard.js +21 -30
- package/dist/ssr/surfaces/NoticeCard.js.map +1 -1
- package/dist/ssr/surfaces/index.js +1 -1
- package/dist/ssr/surfaces/index.js.map +1 -1
- package/dist/styles.css +1 -1
- package/dist/tabler.css +1 -1
- package/dist/types/content/FileView.d.ts +11 -1
- package/dist/types/content/markdown-title.d.ts +9 -0
- package/dist/types/feedback/InstallGuide.d.ts +26 -0
- package/dist/types/feedback/announce.d.ts +19 -0
- package/dist/types/feedback/dialog-core.d.ts +5 -0
- package/dist/types/feedback/index.d.ts +8 -1
- package/dist/types/feedback/install.d.ts +32 -0
- package/dist/types/feedback/prompts.d.ts +2 -0
- package/dist/types/feedback/theme-color.d.ts +6 -0
- package/dist/types/feedback/toast.d.ts +7 -0
- package/dist/types/inputs/DatePicker.d.ts +11 -2
- package/dist/types/inputs/MultiSelectInput.d.ts +5 -1
- package/dist/types/inputs/NumberInput.d.ts +14 -0
- package/dist/types/inputs/QrScanner.d.ts +20 -0
- package/dist/types/inputs/Select.d.ts +10 -0
- package/dist/types/inputs/index.d.ts +3 -1
- package/dist/types/intl/messages.d.ts +44 -0
- package/dist/types/layout/MobileShell.d.ts +40 -0
- package/dist/types/layout/Settings.d.ts +2 -2
- package/dist/types/layout/TabBar.d.ts +23 -0
- package/dist/types/layout/index.d.ts +4 -0
- package/dist/types/surfaces/NoticeCard.d.ts +4 -4
- package/dist/types/surfaces/index.d.ts +1 -1
- package/package.json +3 -2
|
@@ -1,30 +1,44 @@
|
|
|
1
1
|
import { getK2bPortalRoot } from "../internal/portal";
|
|
2
2
|
import { resolveUiMessages } from "../intl/messages";
|
|
3
|
-
|
|
4
|
-
|
|
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-
|
|
22
|
-
titleKey: "error"
|
|
32
|
+
iconClass: "ti-alert-circle"
|
|
23
33
|
}
|
|
24
34
|
};
|
|
25
35
|
|
|
26
|
-
/**
|
|
27
|
-
const
|
|
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
|
|
54
|
-
// through the
|
|
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;
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
118
|
-
let
|
|
247
|
+
let currentAction = options?.action ?? null;
|
|
248
|
+
let explicitDuration = options?.duration;
|
|
249
|
+
let remainingDuration = 0;
|
|
119
250
|
let timerStartedAt = 0;
|
|
120
|
-
let
|
|
121
|
-
|
|
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 =
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
const titleElement = document.createElement("div");
|
|
268
|
+
const titleElement = doc.createElement("div");
|
|
132
269
|
titleElement.className = "k2b-toast__title";
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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 ??
|
|
142
|
-
const closeIcon =
|
|
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 ||
|
|
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 =
|
|
321
|
+
const resetDismissTimer = () => {
|
|
164
322
|
clearDismissTimer();
|
|
165
|
-
|
|
166
|
-
|
|
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(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
actionElement = null;
|
|
194
|
-
if (!action) return;
|
|
369
|
+
const createAction = action => {
|
|
370
|
+
let element;
|
|
195
371
|
if ("href" in action) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
actionElement = link;
|
|
372
|
+
element = doc.createElement("a");
|
|
373
|
+
element.href = action.href;
|
|
199
374
|
} else {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
actionElement = button;
|
|
375
|
+
element = doc.createElement("button");
|
|
376
|
+
element.type = "button";
|
|
203
377
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
504
|
+
for (const item of Array.from(liveToasts)) item.dismiss();
|
|
298
505
|
};
|
|
299
506
|
export const toast = toastFn;
|
|
300
507
|
//# sourceMappingURL=toast.js.map
|