@rozie-ui/toast-lit 0.1.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/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/index.cjs +598 -0
- package/dist/index.d.cts +112 -0
- package/dist/index.d.mts +112 -0
- package/dist/index.mjs +593 -0
- package/package.json +73 -0
- package/src/Toaster.ts +662 -0
- package/src/index.ts +2 -0
- package/src/themes/base.css +57 -0
- package/src/themes/bootstrap.css +27 -0
- package/src/themes/material.css +28 -0
- package/src/themes/shadcn.css +29 -0
package/src/Toaster.ts
ADDED
|
@@ -0,0 +1,662 @@
|
|
|
1
|
+
import { LitElement, css, html, nothing } from 'lit';
|
|
2
|
+
import { customElement, property, queryAssignedElements, state } from 'lit/decorators.js';
|
|
3
|
+
import { SignalWatcher, signal } from '@lit-labs/preact-signals';
|
|
4
|
+
import { rozieAttr, rozieClass, rozieDisplay, rozieListeners, rozieSpread, rozieStyle } from '@rozie/runtime-lit';
|
|
5
|
+
import { repeat } from 'lit/directives/repeat.js';
|
|
6
|
+
|
|
7
|
+
interface RozieToastSlotCtx {
|
|
8
|
+
toast: unknown;
|
|
9
|
+
dismiss: unknown;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
@customElement('rozie-toaster')
|
|
13
|
+
export default class Toaster extends SignalWatcher(LitElement) {
|
|
14
|
+
static styles = css`
|
|
15
|
+
:host{display:contents}
|
|
16
|
+
@media (prefers-reduced-motion: reduce) {
|
|
17
|
+
.rozie-toast[data-rozie-s-12d4265c] {
|
|
18
|
+
animation-name: rozie-toast-fade-in;
|
|
19
|
+
animation-duration: 1ms;
|
|
20
|
+
}
|
|
21
|
+
.rozie-toast--exiting[data-rozie-s-12d4265c] {
|
|
22
|
+
animation-name: rozie-toast-fade-out;
|
|
23
|
+
animation-duration: 1ms;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
.rozie-toaster[data-rozie-s-12d4265c] {
|
|
27
|
+
position: fixed;
|
|
28
|
+
z-index: var(--rozie-toast-z, 9999);
|
|
29
|
+
display: flex;
|
|
30
|
+
flex-direction: column;
|
|
31
|
+
gap: var(--rozie-toast-gap, 0.5rem);
|
|
32
|
+
padding: var(--rozie-toast-region-padding, 1rem);
|
|
33
|
+
max-width: var(--rozie-toast-max-width, calc(100vw - 2rem));
|
|
34
|
+
pointer-events: none;
|
|
35
|
+
font: var(--rozie-toast-font, inherit);
|
|
36
|
+
}
|
|
37
|
+
.rozie-toaster[data-rozie-s-12d4265c] > *[data-rozie-s-12d4265c] {
|
|
38
|
+
pointer-events: auto;
|
|
39
|
+
}
|
|
40
|
+
.rozie-toaster--top-left[data-rozie-s-12d4265c] { top: 0; left: 0; align-items: flex-start; }
|
|
41
|
+
.rozie-toaster--top-right[data-rozie-s-12d4265c] { top: 0; right: 0; align-items: flex-end; }
|
|
42
|
+
.rozie-toaster--top-center[data-rozie-s-12d4265c] { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
|
|
43
|
+
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] { bottom: 0; left: 0; align-items: flex-start; flex-direction: column-reverse; }
|
|
44
|
+
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] { bottom: 0; right: 0; align-items: flex-end; flex-direction: column-reverse; }
|
|
45
|
+
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
|
|
46
|
+
.rozie-toaster--stacked[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
|
|
47
|
+
grid-area: 1 / 1;
|
|
48
|
+
z-index: calc(100 - var(--rozie-toast-depth, 0));
|
|
49
|
+
}
|
|
50
|
+
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) {
|
|
51
|
+
display: grid;
|
|
52
|
+
}
|
|
53
|
+
.rozie-toaster--stacked[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
|
|
54
|
+
transform:
|
|
55
|
+
translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px)))
|
|
56
|
+
scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
|
|
57
|
+
opacity: calc(1 - min(1, max(0, var(--rozie-toast-depth, 0) - 2)));
|
|
58
|
+
}
|
|
59
|
+
.rozie-toaster--stacked.rozie-toaster--bottom-left[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
|
|
60
|
+
.rozie-toaster--stacked.rozie-toaster--bottom-right[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c],
|
|
61
|
+
.rozie-toaster--stacked.rozie-toaster--bottom-center[data-rozie-s-12d4265c]:not([data-rozie-s-12d4265c]:hover):not([data-rozie-s-12d4265c]:focus-within) .rozie-toast[data-rozie-s-12d4265c] {
|
|
62
|
+
transform:
|
|
63
|
+
translateY(calc(var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-offset, 8px) * -1))
|
|
64
|
+
scale(calc(1 - var(--rozie-toast-depth, 0) * var(--rozie-toast-stack-scale-step, 0.05)));
|
|
65
|
+
}
|
|
66
|
+
.rozie-toast[data-rozie-s-12d4265c] {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: var(--rozie-toast-content-gap, 0.75rem);
|
|
70
|
+
min-width: var(--rozie-toast-min-width, 16rem);
|
|
71
|
+
max-width: var(--rozie-toast-toast-max-width, 24rem);
|
|
72
|
+
padding: var(--rozie-toast-padding, 0.75rem 1rem);
|
|
73
|
+
color: var(--rozie-toast-color, #fff);
|
|
74
|
+
background: var(--rozie-toast-bg, #333);
|
|
75
|
+
border-radius: var(--rozie-toast-radius, 0.5rem);
|
|
76
|
+
box-shadow: var(--rozie-toast-shadow, 0 6px 20px rgba(0, 0, 0, 0.25));
|
|
77
|
+
/* Swipe: page scroll stays alive on touch along the axis the toast does
|
|
78
|
+
NOT move on. The transition here drives the spring-back (the active-drag
|
|
79
|
+
:style sets an inline \`transition: none\` to track the finger 1:1;
|
|
80
|
+
releasing it without a further gesture falls back to this transition). */
|
|
81
|
+
touch-action: pan-y;
|
|
82
|
+
transition: transform 200ms ease, opacity 200ms ease;
|
|
83
|
+
}
|
|
84
|
+
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
|
|
85
|
+
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
|
|
86
|
+
touch-action: pan-x;
|
|
87
|
+
}
|
|
88
|
+
.rozie-toast--success[data-rozie-s-12d4265c] { background: var(--rozie-toast-success-bg, #16a34a); }
|
|
89
|
+
.rozie-toast--error[data-rozie-s-12d4265c] { background: var(--rozie-toast-error-bg, #dc2626); }
|
|
90
|
+
.rozie-toast--warning[data-rozie-s-12d4265c] { background: var(--rozie-toast-warning-bg, #ca8a04); }
|
|
91
|
+
.rozie-toast--info[data-rozie-s-12d4265c] { background: var(--rozie-toast-info-bg, var(--rozie-toast-bg, #333)); }
|
|
92
|
+
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
|
|
93
|
+
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
|
|
94
|
+
from[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
|
|
95
|
+
to[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
|
|
96
|
+
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
|
|
97
|
+
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(-0.5rem); }
|
|
98
|
+
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
|
|
99
|
+
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(0.5rem); }
|
|
100
|
+
.rozie-toast[data-rozie-s-12d4265c] {
|
|
101
|
+
animation: rozie-toast-enter var(--rozie-toast-enter-duration, 200ms) ease-out;
|
|
102
|
+
}
|
|
103
|
+
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
|
|
104
|
+
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c],
|
|
105
|
+
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast[data-rozie-s-12d4265c] {
|
|
106
|
+
animation-name: rozie-toast-enter-from-bottom;
|
|
107
|
+
}
|
|
108
|
+
.rozie-toast--exiting[data-rozie-s-12d4265c] {
|
|
109
|
+
animation: rozie-toast-exit var(--rozie-toast-exit-duration, 200ms) ease-in forwards;
|
|
110
|
+
}
|
|
111
|
+
.rozie-toaster--bottom-left[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
|
|
112
|
+
.rozie-toaster--bottom-right[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c],
|
|
113
|
+
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting[data-rozie-s-12d4265c] {
|
|
114
|
+
animation-name: rozie-toast-exit-to-bottom;
|
|
115
|
+
}
|
|
116
|
+
from[data-rozie-s-12d4265c] { opacity: 0; }
|
|
117
|
+
to[data-rozie-s-12d4265c] { opacity: 1; }
|
|
118
|
+
from[data-rozie-s-12d4265c] { opacity: 1; }
|
|
119
|
+
to[data-rozie-s-12d4265c] { opacity: 0; }
|
|
120
|
+
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateX(0); }
|
|
121
|
+
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateX(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
|
|
122
|
+
from[data-rozie-s-12d4265c] { opacity: 1; transform: translateY(0); }
|
|
123
|
+
to[data-rozie-s-12d4265c] { opacity: 0; transform: translateY(calc(var(--rozie-toast-swipe-exit, 1) * 100%)); }
|
|
124
|
+
.rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
|
|
125
|
+
animation-name: rozie-toast-swipe-exit-x;
|
|
126
|
+
}
|
|
127
|
+
.rozie-toaster--top-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c],
|
|
128
|
+
.rozie-toaster--bottom-center[data-rozie-s-12d4265c] .rozie-toast--exiting.rozie-toast--swipe-exit[data-rozie-s-12d4265c] {
|
|
129
|
+
animation-name: rozie-toast-swipe-exit-y;
|
|
130
|
+
}
|
|
131
|
+
.rozie-toast-spinner[data-rozie-s-12d4265c] {
|
|
132
|
+
flex: 0 0 auto;
|
|
133
|
+
width: var(--rozie-toast-spinner-size, 1em);
|
|
134
|
+
height: var(--rozie-toast-spinner-size, 1em);
|
|
135
|
+
border: 2px solid color-mix(in srgb, var(--rozie-toast-spinner-color, currentColor) 25%, transparent);
|
|
136
|
+
border-top-color: var(--rozie-toast-spinner-color, currentColor);
|
|
137
|
+
border-radius: 50%;
|
|
138
|
+
animation: rozie-toast-spin 0.75s linear infinite;
|
|
139
|
+
}
|
|
140
|
+
to[data-rozie-s-12d4265c] { transform: rotate(360deg); }
|
|
141
|
+
.rozie-toast-message[data-rozie-s-12d4265c] {
|
|
142
|
+
flex: 1 1 auto;
|
|
143
|
+
font-size: var(--rozie-toast-font-size, 0.9rem);
|
|
144
|
+
}
|
|
145
|
+
.rozie-toast-close[data-rozie-s-12d4265c] {
|
|
146
|
+
flex: 0 0 auto;
|
|
147
|
+
display: inline-flex;
|
|
148
|
+
align-items: center;
|
|
149
|
+
justify-content: center;
|
|
150
|
+
width: var(--rozie-toast-close-size, 1.25rem);
|
|
151
|
+
height: var(--rozie-toast-close-size, 1.25rem);
|
|
152
|
+
padding: 0;
|
|
153
|
+
font-size: 1.1rem;
|
|
154
|
+
line-height: 1;
|
|
155
|
+
color: inherit;
|
|
156
|
+
background: transparent;
|
|
157
|
+
border: none;
|
|
158
|
+
border-radius: 0.25rem;
|
|
159
|
+
opacity: var(--rozie-toast-close-opacity, 0.75);
|
|
160
|
+
cursor: pointer;
|
|
161
|
+
}
|
|
162
|
+
.rozie-toast-close[data-rozie-s-12d4265c]:hover {
|
|
163
|
+
opacity: 1;
|
|
164
|
+
}
|
|
165
|
+
`;
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Which corner the toast stack renders in: `'top-left'`, `'top-right'`, `'top-center'`, `'bottom-left'`, `'bottom-right'`, or `'bottom-center'`. Drives the fixed-position layout and the stack direction.
|
|
169
|
+
*/
|
|
170
|
+
@property({ type: String, reflect: true }) position: string = 'bottom-right';
|
|
171
|
+
/**
|
|
172
|
+
* Default auto-dismiss time in milliseconds, applied to any toast that does not pass its own `duration`. `0` (or a per-toast `duration` of `0`) makes the toast sticky — it stays until explicitly dismissed.
|
|
173
|
+
*/
|
|
174
|
+
@property({ type: Number, reflect: true }) duration: number = 4000;
|
|
175
|
+
/**
|
|
176
|
+
* Maximum number of visible toasts (`0` = unlimited). When the queue exceeds this, the oldest toasts drop off the stack.
|
|
177
|
+
*/
|
|
178
|
+
@property({ type: Number, reflect: true }) max: number = 0;
|
|
179
|
+
/**
|
|
180
|
+
* Opt **out** of pausing the auto-dismiss timers while the pointer is over the stack. By default hovering pauses every timer and leaving restarts them; set this to keep toasts dismissing on schedule regardless of hover.
|
|
181
|
+
*/
|
|
182
|
+
@property({ type: Boolean, reflect: true }) disablePauseOnHover: boolean = false;
|
|
183
|
+
/**
|
|
184
|
+
* Accessible name for the live region (`role="region"`), applied as its `aria-label`. Defaults to `'Notifications'` when not set, so assistive tech can navigate to the toast stack as a landmark.
|
|
185
|
+
*/
|
|
186
|
+
@property({ type: String, reflect: true }) ariaLabel: string | null = null;
|
|
187
|
+
/**
|
|
188
|
+
* Opt **out** of pointer swipe-to-dismiss. By default, dragging a toast past 45% of its own width/height (direction auto-derived from `position`) or a fast flick dismisses it with reason `'swipe'`; a short drag springs back. A drag starting on the close button (or any button/link) never swipes.
|
|
189
|
+
*/
|
|
190
|
+
@property({ type: Boolean, reflect: true }) disableSwipe: boolean = false;
|
|
191
|
+
/**
|
|
192
|
+
* Opt **in** to a sonner-style collapsed stack: a single-cell grid overlay with depth-driven transforms (toasts at depth 3+ fade to invisible), newest on top. Hovering the region or moving keyboard focus into it expands to the normal flex-column stack; leaving re-collapses. `false` (default) renders the plain flex column at all times.
|
|
193
|
+
*/
|
|
194
|
+
@property({ type: Boolean, reflect: true }) stacked: boolean = false;
|
|
195
|
+
private _toasts = signal<any[]>([]);
|
|
196
|
+
private _seq = signal(0);
|
|
197
|
+
private _swipe = signal<any>(null);
|
|
198
|
+
private _swipeGesture = signal<any>(null);
|
|
199
|
+
|
|
200
|
+
@state() private _hasSlotToast = false;
|
|
201
|
+
@queryAssignedElements({ slot: 'toast', flatten: true }) private _slotToastElements!: Element[];
|
|
202
|
+
@property({ attribute: false }) toast?: (scope: { toast: unknown; dismiss: unknown }) => unknown;
|
|
203
|
+
|
|
204
|
+
private _disconnectCleanups: Array<() => void> = [];
|
|
205
|
+
// Re-parenting guard: set true once the deferred teardown has actually
|
|
206
|
+
// run (a genuine un-mount), so a subsequent reconnect knows to re-arm.
|
|
207
|
+
private _rozieTornDown = false;
|
|
208
|
+
|
|
209
|
+
private _armListeners(): void {
|
|
210
|
+
{
|
|
211
|
+
const slotEl = this.shadowRoot?.querySelector('slot[name="toast"]');
|
|
212
|
+
if (slotEl !== null && slotEl !== undefined) {
|
|
213
|
+
const update = () => { this._hasSlotToast = this._slotToastElements.length > 0; };
|
|
214
|
+
slotEl.addEventListener('slotchange', update);
|
|
215
|
+
// CR-05 fix: push cleanup so the listener is removed on disconnectedCallback.
|
|
216
|
+
this._disconnectCleanups.push(() => slotEl.removeEventListener('slotchange', update));
|
|
217
|
+
update();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
connectedCallback(): void {
|
|
223
|
+
// Phase 07.3.1 D-LIT-15 — pre-seed _hasSlot<X> from light DOM so first render isn't deadlocked.
|
|
224
|
+
this._hasSlotToast = Array.from(this.children).some((el) => el.getAttribute('slot') === 'toast');
|
|
225
|
+
super.connectedCallback();
|
|
226
|
+
if (this.hasUpdated && this._rozieTornDown) { this._rozieTornDown = false; this._armListeners(); }
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
firstUpdated(): void {
|
|
230
|
+
this._armListeners();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
disconnectedCallback(): void {
|
|
234
|
+
super.disconnectedCallback();
|
|
235
|
+
queueMicrotask(() => {
|
|
236
|
+
if (this.isConnected || this._rozieTornDown) return;
|
|
237
|
+
this._rozieTornDown = true;
|
|
238
|
+
() => {
|
|
239
|
+
this.unmounted = true;
|
|
240
|
+
this.teardownTimers();
|
|
241
|
+
};
|
|
242
|
+
for (const fn of this._disconnectCleanups) fn();
|
|
243
|
+
this._disconnectCleanups = [];
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
render() {
|
|
248
|
+
return html`
|
|
249
|
+
<div class="rozie-toaster ${(rozieClass('rozie-toaster--' + this.position + (this.stacked ? ' rozie-toaster--stacked' : '')))}" role="region" aria-label=${rozieAttr(this.regionLabel())} ${rozieSpread(this.$attrs)} @mouseenter=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onMouseEnter(); }} @mouseleave=${($event: MouseEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onMouseLeave(); }} ${rozieListeners(this.$listeners)} data-rozie-s-12d4265c>
|
|
250
|
+
|
|
251
|
+
${repeat<any>(this._toasts.value, (t, _idx) => t.id, (t, _idx) => html`<div class="rozie-toast ${(rozieClass('rozie-toast--' + t.type + (t.exiting ? ' rozie-toast--exiting' : '') + (t.swipeExitSign != null ? ' rozie-toast--swipe-exit' : '')))}" key=${rozieAttr(t.id)} style=${rozieStyle(this.toastStyle(t))} role="status" aria-live=${rozieAttr(this.liveFor(t.type))} @animationend=${($event: Event & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { t.exiting && this.removeToast(t.id); }} @pointerdown=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerDown(t, $event); }} @pointermove=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerMove(t, $event); }} @pointerup=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerUp(t, $event); }} @pointercancel=${($event: PointerEvent & { currentTarget: HTMLDivElement; target: HTMLDivElement }) => { this.onToastPointerCancel(t); }} data-rozie-s-12d4265c>
|
|
252
|
+
${this.toast !== undefined ? this.toast({toast: t, dismiss: this.dismiss}) : html`<slot name="toast" data-rozie-params=${(() => { try { return JSON.stringify({toast: t}); } catch { return '{}'; } })()} @rozie-toast-dismiss=${($event: CustomEvent) => ((this.dismiss) as (...args: any[]) => any)($event.detail)}>
|
|
253
|
+
${t.type === 'loading' ? html`<span class="rozie-toast-spinner" aria-hidden="true" data-rozie-s-12d4265c></span>` : nothing}<span class="rozie-toast-message" data-rozie-s-12d4265c>${rozieDisplay(t.message)}</span>
|
|
254
|
+
<button class="rozie-toast-close" type="button" aria-label="Dismiss" @click=${($event: MouseEvent & { currentTarget: HTMLButtonElement; target: HTMLButtonElement }) => { this.dismissBegin(t.id, 'close'); }} data-rozie-s-12d4265c>×</button>
|
|
255
|
+
</slot>`}
|
|
256
|
+
</div>`)}
|
|
257
|
+
</div>
|
|
258
|
+
`;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
timers = {};
|
|
262
|
+
|
|
263
|
+
exitFailsafes = {};
|
|
264
|
+
|
|
265
|
+
unmounted = false;
|
|
266
|
+
|
|
267
|
+
seqLocal = 0;
|
|
268
|
+
|
|
269
|
+
paused = false;
|
|
270
|
+
|
|
271
|
+
startTimer = (toast: any) => {
|
|
272
|
+
if (!toast || !toast.duration || toast.duration <= 0) return;
|
|
273
|
+
if (typeof window === 'undefined') return;
|
|
274
|
+
// Belt-and-braces: clear any pre-existing live handle for this id before
|
|
275
|
+
// overwriting the entry, so a re-arm never orphans a running timeout.
|
|
276
|
+
const existing = this.timers[toast.id];
|
|
277
|
+
if (existing && existing.handle != null) window.clearTimeout(existing.handle);
|
|
278
|
+
const remaining = toast.duration;
|
|
279
|
+
const handle = window.setTimeout(() => this.dismissBegin(toast.id, 'timeout'), remaining);
|
|
280
|
+
this.timers[toast.id] = {
|
|
281
|
+
handle,
|
|
282
|
+
startedAt: Date.now(),
|
|
283
|
+
remaining
|
|
284
|
+
};
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
clearTimer = (id: any) => {
|
|
288
|
+
const entry = this.timers[id];
|
|
289
|
+
if (entry && entry.handle != null && typeof window !== 'undefined') window.clearTimeout(entry.handle);
|
|
290
|
+
delete this.timers[id];
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
pauseTimers = () => {
|
|
294
|
+
this.paused = true;
|
|
295
|
+
if (typeof window === 'undefined') return;
|
|
296
|
+
for (const id in this.timers) {
|
|
297
|
+
const entry = this.timers[id];
|
|
298
|
+
// Idempotent: an entry already paused (handle cleared) keeps its stored
|
|
299
|
+
// remainder. A second pause must NOT re-subtract elapsed against the
|
|
300
|
+
// original startedAt — that drove `remaining` negative and stranded the
|
|
301
|
+
// toast forever once resume saw the non-positive value.
|
|
302
|
+
if (entry.handle == null) continue;
|
|
303
|
+
window.clearTimeout(entry.handle);
|
|
304
|
+
const elapsed = Date.now() - entry.startedAt;
|
|
305
|
+
// Clamp so a late pause (e.g. a background-tab timer that overran) can
|
|
306
|
+
// never store a negative remainder.
|
|
307
|
+
const remaining = Math.max(0, entry.remaining - elapsed);
|
|
308
|
+
this.timers[id] = {
|
|
309
|
+
handle: null,
|
|
310
|
+
startedAt: entry.startedAt,
|
|
311
|
+
remaining
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
};
|
|
315
|
+
|
|
316
|
+
resumeTimers = () => {
|
|
317
|
+
this.paused = false;
|
|
318
|
+
if (typeof window === 'undefined') return;
|
|
319
|
+
for (const id in this.timers) {
|
|
320
|
+
const entry = this.timers[id];
|
|
321
|
+
// Only re-arm entries that are actually paused (handle cleared). A live
|
|
322
|
+
// handle is left alone — re-arming it would orphan the running timeout.
|
|
323
|
+
if (entry.handle != null) continue;
|
|
324
|
+
if (entry.remaining == null || entry.remaining <= 0) {
|
|
325
|
+
// Its deadline elapsed while paused (a background-tab overrun, or a
|
|
326
|
+
// remainder clamped to 0): treat as EXPIRED and dismiss now — its time
|
|
327
|
+
// is up — rather than leaving it un-armed and stranded forever.
|
|
328
|
+
this.dismissBegin(id, 'timeout');
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
331
|
+
const remaining = entry.remaining;
|
|
332
|
+
const handle = window.setTimeout(() => this.dismissBegin(id, 'timeout'), remaining);
|
|
333
|
+
this.timers[id] = {
|
|
334
|
+
handle,
|
|
335
|
+
startedAt: Date.now(),
|
|
336
|
+
remaining
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
teardownTimers = () => {
|
|
342
|
+
if (typeof window !== 'undefined') {
|
|
343
|
+
for (const id in this.timers) {
|
|
344
|
+
const entry = this.timers[id];
|
|
345
|
+
if (entry.handle != null) window.clearTimeout(entry.handle);
|
|
346
|
+
}
|
|
347
|
+
// Also cancel every pending exit failsafe — otherwise a removal timeout
|
|
348
|
+
// scheduled just before unmount/clear() fires afterward and writes $data.
|
|
349
|
+
for (const id in this.exitFailsafes) {
|
|
350
|
+
if (this.exitFailsafes[id] != null) window.clearTimeout(this.exitFailsafes[id]);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
this.timers = {};
|
|
354
|
+
this.exitFailsafes = {};
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
show = (input: any) => {
|
|
358
|
+
const t = input || {};
|
|
359
|
+
let id;
|
|
360
|
+
if (t.id != null) {
|
|
361
|
+
// Coerce a consumer-supplied id to a String once, at the single entry
|
|
362
|
+
// point. Ids flow through the `timers` map (whose `for (const id in …)`
|
|
363
|
+
// keys are ALWAYS strings) and every downstream `t.id === id` strict
|
|
364
|
+
// comparison; a numeric consumer id (`show({ id: 42 })`) would otherwise
|
|
365
|
+
// stop matching after a hover pause/resume re-arms with the string key.
|
|
366
|
+
id = String(t.id);
|
|
367
|
+
} else {
|
|
368
|
+
// Take the high-water mark of the persistent-but-tick-stale $data.seq and
|
|
369
|
+
// the synchronous-but-maybe-per-render seqLocal (see the <script> comment)
|
|
370
|
+
// so same-tick multi-show yields DISTINCT ids on React too. Read both
|
|
371
|
+
// BEFORE writing either (no read-after-write of $data.seq → ROZ138-safe).
|
|
372
|
+
const s = Math.max(this._seq.value, this.seqLocal);
|
|
373
|
+
id = 't' + s;
|
|
374
|
+
this.seqLocal = s + 1;
|
|
375
|
+
this._seq.value = s + 1;
|
|
376
|
+
}
|
|
377
|
+
const toast = {
|
|
378
|
+
id,
|
|
379
|
+
message: t.message != null ? t.message : '',
|
|
380
|
+
type: t.type || 'info',
|
|
381
|
+
duration: t.duration != null ? t.duration : this.duration
|
|
382
|
+
};
|
|
383
|
+
// ONE self-referential assignment so the React emitter lowers it to the
|
|
384
|
+
// concurrent-safe functional updater `setToasts(prev => …)` (it only does so
|
|
385
|
+
// when the RHS reads $data.toasts DIRECTLY — a via-a-local form lowered to a
|
|
386
|
+
// stale-closure `setToasts(<value>)`, losing the first of two same-tick
|
|
387
|
+
// toasts). slice() start: keep the newest `max` when over the cap
|
|
388
|
+
// (Math.max(0, len+1-max)), else slice(0) = the whole fresh array.
|
|
389
|
+
this._toasts.value = this._toasts.value.concat([toast]).slice(this.max > 0 ? Math.max(0, this._toasts.value.length + 1 - this.max) : 0);
|
|
390
|
+
this.startTimer(toast);
|
|
391
|
+
return id;
|
|
392
|
+
};
|
|
393
|
+
|
|
394
|
+
EXIT_FAILSAFE_MS = 350;
|
|
395
|
+
|
|
396
|
+
removeToast = (id: any) => {
|
|
397
|
+
// Cancel any pending exit failsafe for this id (first-wins: @animationend
|
|
398
|
+
// beating the ~350ms timeout, or vice-versa — either way, only one removal).
|
|
399
|
+
if (typeof window !== 'undefined' && this.exitFailsafes[id] != null) {
|
|
400
|
+
window.clearTimeout(this.exitFailsafes[id]);
|
|
401
|
+
}
|
|
402
|
+
delete this.exitFailsafes[id];
|
|
403
|
+
this._toasts.value = this._toasts.value.filter((t: any) => t.id !== id);
|
|
404
|
+
};
|
|
405
|
+
|
|
406
|
+
dismissBegin = (id: any, reason: any, extra?: {
|
|
407
|
+
swipeExitSign?: number;
|
|
408
|
+
}) => {
|
|
409
|
+
const entry = this._toasts.value.find((t: any) => t.id === id);
|
|
410
|
+
if (!entry || entry.exiting) return;
|
|
411
|
+
this.clearTimer(id);
|
|
412
|
+
this.dispatchEvent(new CustomEvent("dismissed", {
|
|
413
|
+
detail: {
|
|
414
|
+
toast: entry,
|
|
415
|
+
reason
|
|
416
|
+
},
|
|
417
|
+
bubbles: true,
|
|
418
|
+
composed: true
|
|
419
|
+
}));
|
|
420
|
+
this._toasts.value = this._toasts.value.map((t: any) => t.id === id ? {
|
|
421
|
+
...t,
|
|
422
|
+
exiting: true,
|
|
423
|
+
...(extra || {})
|
|
424
|
+
} : t);
|
|
425
|
+
if (typeof window === 'undefined') {
|
|
426
|
+
this.removeToast(id);
|
|
427
|
+
} else {
|
|
428
|
+
this.exitFailsafes[id] = window.setTimeout(() => this.removeToast(id), this.EXIT_FAILSAFE_MS);
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
dismiss = (id: any) => {
|
|
433
|
+
this.dismissBegin(id, 'api');
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
clear = () => {
|
|
437
|
+
this.teardownTimers();
|
|
438
|
+
this._toasts.value = [];
|
|
439
|
+
};
|
|
440
|
+
|
|
441
|
+
patch = (id: any, changes: any) => {
|
|
442
|
+
const c = changes || {};
|
|
443
|
+
let existed = false;
|
|
444
|
+
const next = this._toasts.value.map((t: any) => {
|
|
445
|
+
if (t.id !== id) return t;
|
|
446
|
+
// Treat an EXITING entry as absent — never resurrect a toast whose
|
|
447
|
+
// dismissal is already in flight (removal deferred to @animationend / the
|
|
448
|
+
// failsafe). `existed` stays false → patch returns false, writes nothing,
|
|
449
|
+
// arms no timer.
|
|
450
|
+
if (t.exiting) return t;
|
|
451
|
+
existed = true;
|
|
452
|
+
const merged = {
|
|
453
|
+
...t
|
|
454
|
+
};
|
|
455
|
+
if (c.message !== undefined) merged.message = c.message;
|
|
456
|
+
if (c.type !== undefined) merged.type = c.type;
|
|
457
|
+
if (c.duration !== undefined) merged.duration = c.duration;
|
|
458
|
+
return merged;
|
|
459
|
+
});
|
|
460
|
+
if (!existed) return false;
|
|
461
|
+
this._toasts.value = next;
|
|
462
|
+
if (c.duration !== undefined) {
|
|
463
|
+
this.clearTimer(id);
|
|
464
|
+
const patched = next.find((t: any) => t.id === id);
|
|
465
|
+
if (this.paused) {
|
|
466
|
+
// Hovered: store the new duration as the pending remainder WITHOUT
|
|
467
|
+
// arming a live timer (which would dismiss the toast while the pointer
|
|
468
|
+
// is still over the stack). resumeTimers() arms it on leave.
|
|
469
|
+
if (patched && patched.duration > 0 && typeof window !== 'undefined') {
|
|
470
|
+
this.timers[id] = {
|
|
471
|
+
handle: null,
|
|
472
|
+
startedAt: Date.now(),
|
|
473
|
+
remaining: patched.duration
|
|
474
|
+
};
|
|
475
|
+
}
|
|
476
|
+
} else {
|
|
477
|
+
this.startTimer(patched);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
return true;
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
settlePromise = (id: any, type: any, messageOrFn: any, value: any) => {
|
|
484
|
+
if (this.unmounted) return;
|
|
485
|
+
// Never-resurrect: no-op if the toast is gone OR already exiting (its
|
|
486
|
+
// dismissal is in flight — settling now would flip it back to a live
|
|
487
|
+
// success/error toast and re-arm a timer).
|
|
488
|
+
const entry = this._toasts.value.find((t: any) => t.id === id);
|
|
489
|
+
if (!entry || entry.exiting) return;
|
|
490
|
+
const message = typeof messageOrFn === 'function' ? messageOrFn(value) : messageOrFn;
|
|
491
|
+
this.patch(id, {
|
|
492
|
+
type,
|
|
493
|
+
message,
|
|
494
|
+
duration: this.duration
|
|
495
|
+
});
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
promise = (p: any, opts: any) => {
|
|
499
|
+
const o = opts || {};
|
|
500
|
+
const id = this.show({
|
|
501
|
+
type: 'loading',
|
|
502
|
+
duration: 0,
|
|
503
|
+
message: o.loading
|
|
504
|
+
});
|
|
505
|
+
if (p && typeof p.then === 'function') {
|
|
506
|
+
p.then((value: any) => this.settlePromise(id, 'success', o.success, value)).catch((err: any) => this.settlePromise(id, 'error', o.error, err));
|
|
507
|
+
}
|
|
508
|
+
return id;
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
swipeAxisFor = (position: any) => position === 'top-center' || position === 'bottom-center' ? 'y' : 'x';
|
|
512
|
+
|
|
513
|
+
swipeSignFor = (position: any) => {
|
|
514
|
+
if (position === 'top-right' || position === 'bottom-right') return 1;
|
|
515
|
+
if (position === 'top-left' || position === 'bottom-left') return -1;
|
|
516
|
+
if (position === 'bottom-center') return 1;
|
|
517
|
+
return -1; // top-center
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
onToastPointerDown = (t: any, event: any) => {
|
|
521
|
+
if (this.disableSwipe) return;
|
|
522
|
+
if (event.button != null && event.button !== 0) return;
|
|
523
|
+
// Ignore drags starting on the close button / any button-or-link chrome.
|
|
524
|
+
const chrome = event.target && event.target.closest ? event.target.closest('button, a') : null;
|
|
525
|
+
if (chrome) return;
|
|
526
|
+
const axis = this.swipeAxisFor(this.position);
|
|
527
|
+
const sign = this.swipeSignFor(this.position);
|
|
528
|
+
const el = event.currentTarget;
|
|
529
|
+
const size = axis === 'x' ? el.offsetWidth : el.offsetHeight;
|
|
530
|
+
this._swipeGesture.value = {
|
|
531
|
+
id: t.id,
|
|
532
|
+
axis,
|
|
533
|
+
sign,
|
|
534
|
+
size,
|
|
535
|
+
startX: event.clientX,
|
|
536
|
+
startY: event.clientY,
|
|
537
|
+
startTime: Date.now()
|
|
538
|
+
};
|
|
539
|
+
if (el && el.setPointerCapture) {
|
|
540
|
+
try {
|
|
541
|
+
el.setPointerCapture(event.pointerId);
|
|
542
|
+
} catch (e: any) {
|
|
543
|
+
// Some embedded contexts throw on setPointerCapture — swipe still
|
|
544
|
+
// works without capture (just loses "keeps tracking off-element").
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
};
|
|
548
|
+
|
|
549
|
+
onToastPointerMove = (t: any, event: any) => {
|
|
550
|
+
if (this.disableSwipe) return;
|
|
551
|
+
const gesture = this._swipeGesture.value;
|
|
552
|
+
if (!gesture || gesture.id !== t.id) return;
|
|
553
|
+
const raw = gesture.axis === 'x' ? event.clientX - gesture.startX : event.clientY - gesture.startY;
|
|
554
|
+
const towardDismiss = raw * gesture.sign > 0;
|
|
555
|
+
const d = towardDismiss ? raw : raw * 0.15;
|
|
556
|
+
this._swipe.value = {
|
|
557
|
+
id: t.id,
|
|
558
|
+
d,
|
|
559
|
+
axis: gesture.axis,
|
|
560
|
+
sign: gesture.sign,
|
|
561
|
+
size: gesture.size
|
|
562
|
+
};
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
onToastPointerUp = (t: any, event: any) => {
|
|
566
|
+
if (this.disableSwipe) return;
|
|
567
|
+
const gesture = this._swipeGesture.value;
|
|
568
|
+
this._swipeGesture.value = null;
|
|
569
|
+
// Local named `dragState`, NOT `swipe` — a local `swipe` would shadow the
|
|
570
|
+
// reactive `$data.swipe` key on Svelte 5 (top-level `let swipe = $state(…)`
|
|
571
|
+
// self-shadow TDZ: `const swipe = swipe` then `swipe = null` throws
|
|
572
|
+
// "Cannot assign to constant"). Same collision class as the documented
|
|
573
|
+
// $refs/$props self-shadow, just for a $data key.
|
|
574
|
+
const dragState = this._swipe.value;
|
|
575
|
+
this._swipe.value = null;
|
|
576
|
+
if (!gesture || gesture.id !== t.id || !dragState) return;
|
|
577
|
+
const elapsed = Math.max(1, Date.now() - gesture.startTime);
|
|
578
|
+
const magnitude = dragState.d * gesture.sign;
|
|
579
|
+
const velocity = magnitude / elapsed;
|
|
580
|
+
if (magnitude > 0 && (magnitude > gesture.size * 0.45 || velocity > 0.11)) {
|
|
581
|
+
this.dismissBegin(t.id, 'swipe', {
|
|
582
|
+
swipeExitSign: gesture.sign
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
};
|
|
586
|
+
|
|
587
|
+
onToastPointerCancel = (t: any) => {
|
|
588
|
+
if (this.disableSwipe) return;
|
|
589
|
+
if (this._swipeGesture.value && this._swipeGesture.value.id === t.id) this._swipeGesture.value = null;
|
|
590
|
+
if (this._swipe.value && this._swipe.value.id === t.id) this._swipe.value = null;
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
depth = (t: any) => {
|
|
594
|
+
const idx = this._toasts.value.findIndex((x: any) => x.id === t.id);
|
|
595
|
+
return idx === -1 ? 0 : this._toasts.value.length - 1 - idx;
|
|
596
|
+
};
|
|
597
|
+
|
|
598
|
+
toastStyle = (t: any) => {
|
|
599
|
+
const depthDecl = '--rozie-toast-depth: ' + this.depth(t) + ';';
|
|
600
|
+
if (t.exiting) {
|
|
601
|
+
return t.swipeExitSign != null ? depthDecl + ' --rozie-toast-swipe-exit: ' + t.swipeExitSign + ';' : depthDecl;
|
|
602
|
+
}
|
|
603
|
+
// Local named `dragState`, NOT `swipe` — see the onToastPointerUp comment
|
|
604
|
+
// above (Svelte 5 $data-key self-shadow).
|
|
605
|
+
const dragState = this._swipe.value;
|
|
606
|
+
if (!dragState || dragState.id !== t.id) return depthDecl;
|
|
607
|
+
const translate = dragState.axis === 'x' ? 'translateX(' + dragState.d + 'px)' : 'translateY(' + dragState.d + 'px)';
|
|
608
|
+
const magnitude = dragState.d * dragState.sign;
|
|
609
|
+
const opacity = magnitude > 0 && dragState.size > 0 ? Math.max(0.3, 1 - magnitude / dragState.size) : 1;
|
|
610
|
+
return depthDecl + ' transform: ' + translate + '; opacity: ' + opacity + '; transition: none;';
|
|
611
|
+
};
|
|
612
|
+
|
|
613
|
+
onMouseEnter = () => {
|
|
614
|
+
if (this.disablePauseOnHover) return;
|
|
615
|
+
this.pauseTimers();
|
|
616
|
+
};
|
|
617
|
+
|
|
618
|
+
onMouseLeave = () => {
|
|
619
|
+
if (this.disablePauseOnHover) return;
|
|
620
|
+
this.resumeTimers();
|
|
621
|
+
};
|
|
622
|
+
|
|
623
|
+
regionLabel = () => this.ariaLabel != null ? this.ariaLabel : 'Notifications';
|
|
624
|
+
|
|
625
|
+
liveFor = (type: any) => type === 'error' || type === 'warning' ? 'assertive' : 'polite';
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Plan 14-05 — cross-framework attribute fallthrough source. Reads the
|
|
629
|
+
* host custom element's attributes on each call so a consumer-side bound
|
|
630
|
+
* attribute flows through on every render. The `rozieSpread` directive
|
|
631
|
+
* (D-02) does the cross-render diff downstream.
|
|
632
|
+
*
|
|
633
|
+
* Phase 15 follow-up Bug A — declared-prop attribute names are filtered
|
|
634
|
+
* out so `$attrs` returns "rest after declared props" (semantic parity
|
|
635
|
+
* with React/Vue/Svelte/Solid/Angular). Both Lit attribute-naming
|
|
636
|
+
* forms are folded into the skip set: kebab-case for model props
|
|
637
|
+
* (explicit `attribute:`) AND lowercased property name (Lit's default).
|
|
638
|
+
*/
|
|
639
|
+
private get $attrs(): Record<string, string> {
|
|
640
|
+
const __skip = new Set<string>(['position', 'duration', 'max', 'disable-pause-on-hover', 'disablepauseonhover', 'aria-label', 'arialabel', 'disable-swipe', 'disableswipe', 'stacked']);
|
|
641
|
+
const out: Record<string, string> = {};
|
|
642
|
+
for (const a of Array.from(this.attributes)) {
|
|
643
|
+
if (__skip.has(a.name)) continue;
|
|
644
|
+
out[a.name] = a.value;
|
|
645
|
+
}
|
|
646
|
+
return out;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Phase 15 D-19 — consumer-passed listener cluster placeholder.
|
|
651
|
+
* Lit attaches event listeners directly on the host element via
|
|
652
|
+
* `addEventListener` (no per-instance prop rest binding), so the
|
|
653
|
+
* runtime value is undefined; the `rozieListeners` directive's
|
|
654
|
+
* nullish coercion (`obj ?? {}`) handles the no-op cleanly.
|
|
655
|
+
* The declaration exists to satisfy `tsc --noEmit` on consumer
|
|
656
|
+
* projects with strict mode — bare `$listeners` in `render()`
|
|
657
|
+
* would otherwise raise TS2304 (Cannot find name).
|
|
658
|
+
*/
|
|
659
|
+
private get $listeners(): Record<string, EventListener> | undefined {
|
|
660
|
+
return undefined;
|
|
661
|
+
}
|
|
662
|
+
}
|
package/src/index.ts
ADDED