@k2b/ui 0.10.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.
- package/dist/browser/content/FileView.js +203 -136
- package/dist/browser/content/FileView.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/HoverPreview.js +6 -6
- package/dist/browser/feedback/HoverPreview.js.map +1 -1
- package/dist/browser/feedback/index.js.map +1 -1
- package/dist/browser/feedback/prompts.js +18 -19
- package/dist/browser/feedback/prompts.js.map +1 -1
- package/dist/browser/feedback/toast.js +363 -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/Select.js +184 -92
- package/dist/browser/inputs/Select.js.map +1 -1
- package/dist/browser/inputs/index.js.map +1 -1
- package/dist/browser/intl/messages.js +4 -0
- package/dist/browser/intl/messages.js.map +1 -1
- package/dist/browser/layout/AppWorkspace.js +105 -77
- package/dist/browser/layout/AppWorkspace.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/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/app-workspace-controller.js +30 -2
- package/dist/browser/layout/app-workspace-controller.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/markdown-title.js +52 -0
- package/dist/ssr/content/markdown-title.js.map +1 -0
- package/dist/ssr/feedback/HoverPreview.js +6 -6
- package/dist/ssr/feedback/HoverPreview.js.map +1 -1
- package/dist/ssr/feedback/index.js.map +1 -1
- package/dist/ssr/feedback/prompts.js +1 -1
- package/dist/ssr/feedback/prompts.js.map +1 -1
- package/dist/ssr/feedback/toast.js +363 -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/Select.js +108 -29
- package/dist/ssr/inputs/Select.js.map +1 -1
- package/dist/ssr/inputs/index.js.map +1 -1
- package/dist/ssr/intl/messages.js +4 -0
- package/dist/ssr/intl/messages.js.map +1 -1
- package/dist/ssr/layout/AppWorkspace.js +84 -66
- package/dist/ssr/layout/AppWorkspace.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/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/app-workspace-controller.js +30 -2
- package/dist/ssr/layout/app-workspace-controller.js.map +1 -1
- package/dist/styles.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/index.d.ts +1 -1
- 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/Select.d.ts +10 -0
- package/dist/types/inputs/index.d.ts +1 -1
- package/dist/types/intl/messages.d.ts +2 -0
- package/dist/types/layout/AppWorkspace.d.ts +14 -0
- package/dist/types/layout/Settings.d.ts +2 -2
- package/dist/types/layout/app-workspace-controller.d.ts +5 -0
- package/package.json +1 -1
|
@@ -1,30 +1,47 @@
|
|
|
1
1
|
import { getK2bPortalRoot } from "../internal/portal";
|
|
2
2
|
import { resolveUiMessages } from "../intl/messages";
|
|
3
|
-
|
|
4
|
-
const MAX_VISIBLE_TOASTS =
|
|
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-
|
|
22
|
-
titleKey: "error"
|
|
35
|
+
iconClass: "ti-alert-circle"
|
|
23
36
|
}
|
|
24
37
|
};
|
|
25
38
|
|
|
26
|
-
/**
|
|
27
|
-
const
|
|
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
|
|
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;
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
|
118
|
-
let
|
|
287
|
+
let currentAction = options?.action ?? null;
|
|
288
|
+
let explicitDuration = options?.duration;
|
|
289
|
+
let remainingDuration = 0;
|
|
119
290
|
let timerStartedAt = 0;
|
|
120
|
-
let
|
|
121
|
-
|
|
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 =
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
131
|
-
const titleElement = document.createElement("div");
|
|
308
|
+
const titleElement = doc.createElement("div");
|
|
132
309
|
titleElement.className = "k2b-toast__title";
|
|
133
|
-
|
|
134
|
-
|
|
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 =
|
|
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 ??
|
|
142
|
-
const closeIcon =
|
|
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 ||
|
|
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 =
|
|
359
|
+
const resetDismissTimer = () => {
|
|
164
360
|
clearDismissTimer();
|
|
165
|
-
|
|
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(
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
|
|
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
|
|
192
|
-
|
|
193
|
-
actionElement = null;
|
|
194
|
-
if (!action) return;
|
|
405
|
+
const createAction = action => {
|
|
406
|
+
let element;
|
|
195
407
|
if ("href" in action) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
actionElement = link;
|
|
408
|
+
element = doc.createElement("a");
|
|
409
|
+
element.href = action.href;
|
|
199
410
|
} else {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
actionElement = button;
|
|
411
|
+
element = doc.createElement("button");
|
|
412
|
+
element.type = "button";
|
|
203
413
|
}
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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;
|
|
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(
|
|
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
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
538
|
+
for (const item of Array.from(liveToasts)) item.dismiss();
|
|
298
539
|
};
|
|
299
540
|
export const toast = toastFn;
|
|
300
541
|
//# sourceMappingURL=toast.js.map
|