ajo-ui 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 +15 -0
- package/README.md +126 -0
- package/dist/accordion.js +130 -0
- package/dist/avatar.js +54 -0
- package/dist/calendar.js +2 -0
- package/dist/carousel.js +239 -0
- package/dist/chart.js +790 -0
- package/dist/checkbox-group.js +70 -0
- package/dist/checkbox.js +77 -0
- package/dist/chunks/bar-CVafh6C1.js +99 -0
- package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
- package/dist/chunks/collection-DtRB63U4.js +111 -0
- package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
- package/dist/chunks/menu-B45IyHHC.js +704 -0
- package/dist/chunks/native-BJdhd9XJ.js +20 -0
- package/dist/chunks/popup-C8Bb3l_g.js +459 -0
- package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
- package/dist/chunks/position-D6_i_SRn.js +434 -0
- package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
- package/dist/collapsible.js +106 -0
- package/dist/command.js +263 -0
- package/dist/context-menu.js +112 -0
- package/dist/data-table.js +5 -0
- package/dist/dialog.js +207 -0
- package/dist/direction.js +22 -0
- package/dist/drawer.js +139 -0
- package/dist/field.js +26 -0
- package/dist/index.js +38 -0
- package/dist/input-date.js +994 -0
- package/dist/input-group.js +52 -0
- package/dist/input-otp.js +179 -0
- package/dist/menu.js +2 -0
- package/dist/menubar.js +236 -0
- package/dist/message-scroller.js +446 -0
- package/dist/navigation-menu.js +330 -0
- package/dist/popover.js +307 -0
- package/dist/progress.js +39 -0
- package/dist/radio-group.js +107 -0
- package/dist/resizable.js +172 -0
- package/dist/select.js +961 -0
- package/dist/sidebar.js +343 -0
- package/dist/slider.js +259 -0
- package/dist/switch.js +53 -0
- package/dist/tabs.js +182 -0
- package/dist/toast.js +492 -0
- package/dist/toggle-group.js +111 -0
- package/dist/toggle.js +52 -0
- package/dist/toolbar.js +127 -0
- package/dist/tooltip.js +196 -0
- package/dist/utils.js +104 -0
- package/dist/virtual-list.js +2 -0
- package/package.json +250 -0
- package/src/accordion.tsx +261 -0
- package/src/availability.ts +261 -0
- package/src/avatar.tsx +99 -0
- package/src/bar.ts +156 -0
- package/src/calendar.tsx +1441 -0
- package/src/carousel.tsx +424 -0
- package/src/chart.tsx +1194 -0
- package/src/checkbox-group.tsx +132 -0
- package/src/checkbox.tsx +130 -0
- package/src/collapsible.tsx +188 -0
- package/src/collection.ts +154 -0
- package/src/command.tsx +511 -0
- package/src/context-menu.tsx +233 -0
- package/src/data-table-contract.ts +143 -0
- package/src/data-table-model.ts +760 -0
- package/src/data-table.tsx +475 -0
- package/src/dialog.tsx +393 -0
- package/src/direction.tsx +45 -0
- package/src/drawer.tsx +251 -0
- package/src/field.tsx +61 -0
- package/src/index.ts +37 -0
- package/src/input-date.tsx +1539 -0
- package/src/input-group.tsx +142 -0
- package/src/input-otp.tsx +324 -0
- package/src/menu-cluster.ts +124 -0
- package/src/menu.tsx +1095 -0
- package/src/menubar.tsx +459 -0
- package/src/message-scroller.tsx +732 -0
- package/src/native.ts +26 -0
- package/src/navigation-menu.tsx +578 -0
- package/src/popover.tsx +519 -0
- package/src/popup-surface.tsx +31 -0
- package/src/popup.ts +569 -0
- package/src/position.ts +523 -0
- package/src/progress.tsx +70 -0
- package/src/radio-group.tsx +186 -0
- package/src/resizable.tsx +310 -0
- package/src/segments.ts +922 -0
- package/src/select.tsx +1501 -0
- package/src/sidebar.tsx +683 -0
- package/src/slider.tsx +424 -0
- package/src/switch.tsx +104 -0
- package/src/tabs.tsx +314 -0
- package/src/toast.tsx +923 -0
- package/src/toggle-group.tsx +249 -0
- package/src/toggle.tsx +91 -0
- package/src/toolbar.tsx +212 -0
- package/src/tooltip.tsx +359 -0
- package/src/utils.ts +204 -0
- package/src/virtual-list.tsx +205 -0
- package/src/virtual.ts +385 -0
package/src/toast.tsx
ADDED
|
@@ -0,0 +1,923 @@
|
|
|
1
|
+
import type { Children, IntrinsicElements, Stateful, Stateless, WithChildren } from 'ajo'
|
|
2
|
+
import { render } from 'ajo'
|
|
3
|
+
import { browser, callHandler, hotkey as bindHotkey, id } from 'ajo-cloves'
|
|
4
|
+
import { closePopover, openPopover } from './native'
|
|
5
|
+
import { clx, popupStyle, toNumber, type FixedArgs, type OmitArg } from './utils'
|
|
6
|
+
|
|
7
|
+
/** Visual tone applied to a toast. */
|
|
8
|
+
export type ToastVariant =
|
|
9
|
+
| 'default'
|
|
10
|
+
| 'danger'
|
|
11
|
+
| 'info'
|
|
12
|
+
| 'success'
|
|
13
|
+
| 'warning'
|
|
14
|
+
|
|
15
|
+
/** Live-region priority category used by a toast. */
|
|
16
|
+
export type ToastType =
|
|
17
|
+
| 'background'
|
|
18
|
+
| 'foreground'
|
|
19
|
+
|
|
20
|
+
/** Fixed viewport position for generated toasts. */
|
|
21
|
+
export type ToastPosition =
|
|
22
|
+
| 'bottom-center'
|
|
23
|
+
| 'bottom-left'
|
|
24
|
+
| 'bottom-right'
|
|
25
|
+
| 'top-center'
|
|
26
|
+
| 'top-left'
|
|
27
|
+
| 'top-right'
|
|
28
|
+
|
|
29
|
+
/** Props for an individual toast item. */
|
|
30
|
+
export type ToastArgs = WithChildren<IntrinsicElements['li'] & {
|
|
31
|
+
/** Controlled visibility. Hidden toasts are not rendered. */
|
|
32
|
+
open?: boolean
|
|
33
|
+
/** Toast priority hint for live-region behavior. */
|
|
34
|
+
type?: ToastType
|
|
35
|
+
/** Toast tone used for data attrs and default live-region role. */
|
|
36
|
+
variant?: ToastVariant
|
|
37
|
+
/** Additional classes supplied by a styled wrapper. */
|
|
38
|
+
class?: string
|
|
39
|
+
}>
|
|
40
|
+
|
|
41
|
+
/** Props for an action button associated with a toast. */
|
|
42
|
+
export type ToastActionArgs = WithChildren<IntrinsicElements['button'] & {
|
|
43
|
+
/** Alternative text for non-textual actions. */
|
|
44
|
+
altText?: string
|
|
45
|
+
/** Called after the action is selected. */
|
|
46
|
+
onAction?: () => void
|
|
47
|
+
/** Additional classes supplied by a styled wrapper. */
|
|
48
|
+
class?: string
|
|
49
|
+
}>
|
|
50
|
+
|
|
51
|
+
/** Props for a button that dismisses its toast. */
|
|
52
|
+
export type ToastCloseArgs = WithChildren<IntrinsicElements['button'] & {
|
|
53
|
+
/** Called after the close button is selected. */
|
|
54
|
+
onClose?: () => void
|
|
55
|
+
/** Additional classes supplied by a styled wrapper. */
|
|
56
|
+
class?: string
|
|
57
|
+
}>
|
|
58
|
+
|
|
59
|
+
/** Shared props for textual toast slots. */
|
|
60
|
+
export type ToastSlotArgs = WithChildren<IntrinsicElements['div'] & {
|
|
61
|
+
/** Additional classes supplied by a styled wrapper. */
|
|
62
|
+
class?: string
|
|
63
|
+
}>
|
|
64
|
+
|
|
65
|
+
/** Props for a toast's title slot. */
|
|
66
|
+
export type ToastTitleArgs = ToastSlotArgs
|
|
67
|
+
/** Props for a toast's description slot. */
|
|
68
|
+
export type ToastDescriptionArgs = ToastSlotArgs
|
|
69
|
+
|
|
70
|
+
/** Props for the ordered viewport that displays toast items. */
|
|
71
|
+
export type ToastViewportArgs = WithChildren<IntrinsicElements['ol'] & {
|
|
72
|
+
/** Keyboard shortcut shown in the accessible viewport label. */
|
|
73
|
+
hotkey?: string[]
|
|
74
|
+
/** Accessible label for the toast viewport. */
|
|
75
|
+
label?: string
|
|
76
|
+
/** Native popover mode. Manual keeps toasts above modal dialogs without light dismiss. */
|
|
77
|
+
popover?: 'auto' | 'manual'
|
|
78
|
+
/** Fixed screen position for generated toasts. */
|
|
79
|
+
position?: ToastPosition
|
|
80
|
+
/** Additional classes supplied by a styled wrapper. */
|
|
81
|
+
class?: string
|
|
82
|
+
}>
|
|
83
|
+
|
|
84
|
+
/** Props for defaults inherited by generated toasts. */
|
|
85
|
+
export type ToastProviderArgs = WithChildren<{
|
|
86
|
+
/** Default auto-dismiss duration for generated toasts. */
|
|
87
|
+
duration?: number
|
|
88
|
+
/** Accessible label for generated toast viewports. */
|
|
89
|
+
label?: string
|
|
90
|
+
}>
|
|
91
|
+
|
|
92
|
+
/** Data accepted when creating or updating an imperative toast. */
|
|
93
|
+
export type ToastInput = {
|
|
94
|
+
/** Stable id for updating or dismissing this toast. */
|
|
95
|
+
id?: string
|
|
96
|
+
/** Toast title. */
|
|
97
|
+
title?: unknown
|
|
98
|
+
/** Toast body copy. */
|
|
99
|
+
description?: unknown
|
|
100
|
+
/** Optional action element. Prefer ToastAction. */
|
|
101
|
+
action?: unknown
|
|
102
|
+
/** Additional root classes. */
|
|
103
|
+
class?: string
|
|
104
|
+
/** Show this generated toast's close button. */
|
|
105
|
+
closeButton?: boolean
|
|
106
|
+
/** Auto-dismiss duration in milliseconds. Pass 0 to keep it open. */
|
|
107
|
+
duration?: number
|
|
108
|
+
/** Fixed screen position for this generated toast. */
|
|
109
|
+
position?: ToastPosition
|
|
110
|
+
/** Accessible role. Defaults to status, or alert for danger toasts. */
|
|
111
|
+
role?: string
|
|
112
|
+
/** Toast priority hint for live-region behavior. */
|
|
113
|
+
type?: ToastType
|
|
114
|
+
/** Toast tone used for data attrs and default live-region role. */
|
|
115
|
+
variant?: ToastVariant
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Controller returned after an imperative toast is created. */
|
|
119
|
+
export type ToastController = {
|
|
120
|
+
id: string
|
|
121
|
+
dismiss: () => void
|
|
122
|
+
update: (patch: ToastInput) => void
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Renderable snapshot of a generated toast. */
|
|
126
|
+
export type ToastView = ToastInput & {
|
|
127
|
+
id: string
|
|
128
|
+
open: boolean
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// `ref` is omitted: the runtime routes a stateful component's ref to its
|
|
132
|
+
// host, so the viewport element ref cannot be part of the public args.
|
|
133
|
+
/** Props for the managed viewport that renders imperative toasts. */
|
|
134
|
+
export type ToasterArgs = OmitArg<ToastViewportArgs, 'children' | 'ref'> & {
|
|
135
|
+
/** Show every visible toast at full size instead of the compact stack. */
|
|
136
|
+
expand?: boolean
|
|
137
|
+
/** Show close buttons on generated toasts. */
|
|
138
|
+
closeButton?: boolean
|
|
139
|
+
/** Default auto-dismiss duration for generated toasts. */
|
|
140
|
+
duration?: number
|
|
141
|
+
/** Maximum visible generated toasts. */
|
|
142
|
+
limit?: number
|
|
143
|
+
/** Pause generated toast timers while the window is blurred. */
|
|
144
|
+
pauseOnWindowBlur?: boolean
|
|
145
|
+
contentClass?: string
|
|
146
|
+
actionWrapperClass?: string
|
|
147
|
+
closeClass?: string
|
|
148
|
+
closeChildren?: Children
|
|
149
|
+
descriptionClass?: string
|
|
150
|
+
titleClass?: string
|
|
151
|
+
toastClass?: string | ((toast: ToastView) => string | undefined)
|
|
152
|
+
} & FixedArgs<'children' | 'ref'>
|
|
153
|
+
|
|
154
|
+
type ToastRecord = ToastInput & {
|
|
155
|
+
id: string
|
|
156
|
+
open: boolean
|
|
157
|
+
remaining: number
|
|
158
|
+
startedAt: number
|
|
159
|
+
timeout: ReturnType<typeof setTimeout> | null
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
type ToastListener = () => void
|
|
163
|
+
|
|
164
|
+
const defaultDuration = 5000
|
|
165
|
+
const defaultLimit = 3
|
|
166
|
+
const closeDelay = 200
|
|
167
|
+
const hotkeyDefault = ['F8']
|
|
168
|
+
const edgeInset = 16
|
|
169
|
+
const stackGap = 8
|
|
170
|
+
|
|
171
|
+
let records: ToastRecord[] = []
|
|
172
|
+
let configuredDuration = defaultDuration
|
|
173
|
+
let configuredLabel: string | undefined
|
|
174
|
+
let configuredPosition: ToastPosition = 'bottom-right'
|
|
175
|
+
const listeners = new Set<ToastListener>()
|
|
176
|
+
|
|
177
|
+
const emit = () => {
|
|
178
|
+
for (const listener of listeners) listener()
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const clearTimer = (record: ToastRecord) => {
|
|
182
|
+
if (record.timeout) clearTimeout(record.timeout)
|
|
183
|
+
record.timeout = null
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const removeToast = (id: string) => {
|
|
187
|
+
records = records.filter(record => record.id !== id)
|
|
188
|
+
emit()
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const startTimer = (record: ToastRecord) => {
|
|
192
|
+
clearTimer(record)
|
|
193
|
+
if (!browser() || record.remaining <= 0) return
|
|
194
|
+
|
|
195
|
+
record.startedAt = Date.now()
|
|
196
|
+
record.timeout = setTimeout(() => dismissToast(record.id), record.remaining)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const pauseRecord = (record: ToastRecord) => {
|
|
200
|
+
if (!record.timeout) return
|
|
201
|
+
|
|
202
|
+
clearTimer(record)
|
|
203
|
+
record.remaining = Math.max(0, record.remaining - (Date.now() - record.startedAt))
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const resumeRecord = (record: ToastRecord) => {
|
|
207
|
+
if (!record.open || record.timeout || record.remaining <= 0) return
|
|
208
|
+
startTimer(record)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const normalize = (input: ToastInput | unknown, options?: Omit<ToastInput, 'title'>): ToastInput => {
|
|
212
|
+
if (
|
|
213
|
+
input &&
|
|
214
|
+
typeof input === 'object' &&
|
|
215
|
+
(
|
|
216
|
+
'id' in input ||
|
|
217
|
+
'title' in input ||
|
|
218
|
+
'description' in input ||
|
|
219
|
+
'action' in input ||
|
|
220
|
+
'closeButton' in input ||
|
|
221
|
+
'variant' in input ||
|
|
222
|
+
'duration' in input ||
|
|
223
|
+
'position' in input
|
|
224
|
+
)
|
|
225
|
+
) return input as ToastInput
|
|
226
|
+
|
|
227
|
+
return { ...options, title: input }
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const visibleToasts = (limit = defaultLimit, position?: ToastPosition) => {
|
|
231
|
+
const scoped = position
|
|
232
|
+
? records.filter(record => record.position === position)
|
|
233
|
+
: records
|
|
234
|
+
|
|
235
|
+
return scoped.slice(0, Math.max(1, limit))
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const subscribeToasts = (listener: ToastListener) => {
|
|
239
|
+
listeners.add(listener)
|
|
240
|
+
return () => listeners.delete(listener)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const stringValue = (value: unknown) =>
|
|
244
|
+
typeof value === 'string' ? value : undefined
|
|
245
|
+
|
|
246
|
+
const booleanValue = (value: unknown, fallback: boolean) =>
|
|
247
|
+
typeof value === 'boolean' ? value : fallback
|
|
248
|
+
|
|
249
|
+
const hotkeyValue = (value: unknown) =>
|
|
250
|
+
Array.isArray(value) && value.every(item => typeof item === 'string')
|
|
251
|
+
? value
|
|
252
|
+
: hotkeyDefault
|
|
253
|
+
|
|
254
|
+
const positions = new Set<ToastPosition>([
|
|
255
|
+
'bottom-center',
|
|
256
|
+
'bottom-left',
|
|
257
|
+
'bottom-right',
|
|
258
|
+
'top-center',
|
|
259
|
+
'top-left',
|
|
260
|
+
'top-right',
|
|
261
|
+
])
|
|
262
|
+
|
|
263
|
+
const positionValue = (value: unknown): ToastPosition =>
|
|
264
|
+
typeof value === 'string' && positions.has(value as ToastPosition)
|
|
265
|
+
? value as ToastPosition
|
|
266
|
+
: 'bottom-right'
|
|
267
|
+
|
|
268
|
+
const composeClick = (
|
|
269
|
+
previous: unknown,
|
|
270
|
+
next: (() => void) | undefined,
|
|
271
|
+
) => (event: MouseEvent) => {
|
|
272
|
+
callHandler(previous, event)
|
|
273
|
+
next?.()
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const resolveClass = (
|
|
277
|
+
value: ToasterArgs['toastClass'],
|
|
278
|
+
toast: ToastView,
|
|
279
|
+
) => typeof value === 'function' ? value(toast) : value
|
|
280
|
+
|
|
281
|
+
/** Add a generated toast and return controls for updating or dismissing it. */
|
|
282
|
+
export const toast = (
|
|
283
|
+
input: ToastInput | unknown,
|
|
284
|
+
options?: Omit<ToastInput, 'title'>,
|
|
285
|
+
): ToastController => {
|
|
286
|
+
const data = normalize(input, options)
|
|
287
|
+
const toastId = data.id ?? id('toast')
|
|
288
|
+
const existing = records.find(record => record.id === toastId)
|
|
289
|
+
const duration = data.duration ?? existing?.duration ?? configuredDuration
|
|
290
|
+
const record: ToastRecord = {
|
|
291
|
+
...existing,
|
|
292
|
+
...data,
|
|
293
|
+
id: toastId,
|
|
294
|
+
duration,
|
|
295
|
+
open: true,
|
|
296
|
+
remaining: Math.max(0, duration),
|
|
297
|
+
startedAt: 0,
|
|
298
|
+
timeout: existing?.timeout ?? null,
|
|
299
|
+
variant: data.variant ?? existing?.variant ?? 'default',
|
|
300
|
+
position: data.position ?? existing?.position ?? configuredPosition,
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (existing) {
|
|
304
|
+
clearTimer(existing)
|
|
305
|
+
records = records.map(current => current.id === toastId ? record : current)
|
|
306
|
+
} else {
|
|
307
|
+
records = [record, ...records]
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
startTimer(record)
|
|
311
|
+
emit()
|
|
312
|
+
|
|
313
|
+
return {
|
|
314
|
+
id: toastId,
|
|
315
|
+
dismiss: () => dismissToast(toastId),
|
|
316
|
+
update: patch => updateToast(toastId, patch),
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Update an existing generated toast. */
|
|
321
|
+
export const updateToast = (id: string, patch: ToastInput) => {
|
|
322
|
+
const record = records.find(current => current.id === id)
|
|
323
|
+
if (!record) return
|
|
324
|
+
|
|
325
|
+
clearTimer(record)
|
|
326
|
+
Object.assign(record, patch, {
|
|
327
|
+
duration: patch.duration ?? record.duration,
|
|
328
|
+
remaining: Math.max(0, patch.duration ?? record.duration ?? defaultDuration),
|
|
329
|
+
})
|
|
330
|
+
startTimer(record)
|
|
331
|
+
emit()
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** Dismiss one generated toast, or every generated toast when no id is passed. */
|
|
335
|
+
export const dismissToast = (id?: string) => {
|
|
336
|
+
const targets = id ? records.filter(record => record.id === id) : records
|
|
337
|
+
|
|
338
|
+
for (const record of targets) {
|
|
339
|
+
clearTimer(record)
|
|
340
|
+
record.open = false
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
emit()
|
|
344
|
+
|
|
345
|
+
if (!browser()) {
|
|
346
|
+
records = id ? records.filter(record => record.id !== id) : []
|
|
347
|
+
emit()
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
for (const record of targets) {
|
|
352
|
+
setTimeout(() => removeToast(record.id), closeDelay)
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/** Remove all generated toasts immediately. Useful for deterministic tests and stories. */
|
|
357
|
+
export const clearToasts = () => {
|
|
358
|
+
for (const record of records) clearTimer(record)
|
|
359
|
+
records = []
|
|
360
|
+
emit()
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Provider for toast defaults. */
|
|
364
|
+
const ToastProvider: Stateless<ToastProviderArgs> = ({
|
|
365
|
+
children,
|
|
366
|
+
duration = defaultDuration,
|
|
367
|
+
label,
|
|
368
|
+
}) => {
|
|
369
|
+
configuredDuration = duration
|
|
370
|
+
configuredLabel = label
|
|
371
|
+
return children
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Inline corner placement per position. The popupStyle reset zeroes the UA
|
|
375
|
+
// popover inset, and inline declarations are the only ones that can win over
|
|
376
|
+
// that reset, so the operative placement lives here; themed position classes
|
|
377
|
+
// agree with (and never fight) these values.
|
|
378
|
+
const viewportInsets: Record<ToastPosition, string> = {
|
|
379
|
+
'bottom-center': 'bottom:0;left:50%;transform:translateX(-50%)',
|
|
380
|
+
'bottom-left': 'bottom:0;left:0',
|
|
381
|
+
'bottom-right': 'bottom:0;right:0',
|
|
382
|
+
'top-center': 'top:0;left:50%;transform:translateX(-50%)',
|
|
383
|
+
'top-left': 'top:0;left:0',
|
|
384
|
+
'top-right': 'top:0;right:0',
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Toast viewport where generated or manually composed toasts are rendered. */
|
|
388
|
+
const ToastViewport: Stateless<ToastViewportArgs> = ({
|
|
389
|
+
children,
|
|
390
|
+
class: classes,
|
|
391
|
+
hotkey = hotkeyDefault,
|
|
392
|
+
label,
|
|
393
|
+
popover = 'manual',
|
|
394
|
+
position = 'bottom-right',
|
|
395
|
+
style,
|
|
396
|
+
tabIndex = -1,
|
|
397
|
+
...attrs
|
|
398
|
+
}) => {
|
|
399
|
+
const hotkeyText = hotkey.join('+')
|
|
400
|
+
const title = label ?? configuredLabel ?? `Notifications (${hotkeyText})`
|
|
401
|
+
|
|
402
|
+
return (
|
|
403
|
+
<ol
|
|
404
|
+
{...attrs}
|
|
405
|
+
aria-atomic="false"
|
|
406
|
+
aria-label={title}
|
|
407
|
+
aria-live="polite"
|
|
408
|
+
class={classes}
|
|
409
|
+
data-hotkey={hotkeyText}
|
|
410
|
+
data-position={position}
|
|
411
|
+
data-slot="toast-viewport"
|
|
412
|
+
popover={popover}
|
|
413
|
+
role="region"
|
|
414
|
+
style={popupStyle(
|
|
415
|
+
// Beyond inset/margin, the UA popover rule paints a bordered
|
|
416
|
+
// Canvas box and clips overflow; the viewport is a transparent
|
|
417
|
+
// stacking container whose behind toasts peek past its box, so
|
|
418
|
+
// restore those to neutral before placing the corner.
|
|
419
|
+
'border:none;background:transparent;color:inherit;overflow:visible',
|
|
420
|
+
viewportInsets[position],
|
|
421
|
+
style,
|
|
422
|
+
)}
|
|
423
|
+
tabIndex={tabIndex}
|
|
424
|
+
>
|
|
425
|
+
{children}
|
|
426
|
+
</ol>
|
|
427
|
+
)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Toast root for composed notifications. */
|
|
431
|
+
const Toast: Stateless<ToastArgs> = ({
|
|
432
|
+
children,
|
|
433
|
+
class: classes,
|
|
434
|
+
open = true,
|
|
435
|
+
role,
|
|
436
|
+
type = 'foreground',
|
|
437
|
+
variant = 'default',
|
|
438
|
+
...attrs
|
|
439
|
+
}) => open ? (
|
|
440
|
+
<li
|
|
441
|
+
{...attrs}
|
|
442
|
+
class={classes}
|
|
443
|
+
data-slot="toast"
|
|
444
|
+
data-state="open"
|
|
445
|
+
data-type={type}
|
|
446
|
+
data-variant={variant}
|
|
447
|
+
role={role ?? (variant === 'danger' ? 'alert' : 'status')}
|
|
448
|
+
>
|
|
449
|
+
{children}
|
|
450
|
+
</li>
|
|
451
|
+
) : null
|
|
452
|
+
|
|
453
|
+
/** Toast title slot. */
|
|
454
|
+
const ToastTitle: Stateless<ToastTitleArgs> = ({ children, class: classes, ...attrs }) => (
|
|
455
|
+
<div {...attrs} class={classes} data-slot="toast-title">
|
|
456
|
+
{children}
|
|
457
|
+
</div>
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
/** Toast description slot. */
|
|
461
|
+
const ToastDescription: Stateless<ToastDescriptionArgs> = ({ children, class: classes, ...attrs }) => (
|
|
462
|
+
<div {...attrs} class={classes} data-slot="toast-description">
|
|
463
|
+
{children}
|
|
464
|
+
</div>
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
/** Toast action button. */
|
|
468
|
+
const ToastAction: Stateless<ToastActionArgs> = ({
|
|
469
|
+
altText,
|
|
470
|
+
children,
|
|
471
|
+
class: classes,
|
|
472
|
+
onAction,
|
|
473
|
+
type = 'button',
|
|
474
|
+
'set:onclick': setOnClick,
|
|
475
|
+
...attrs
|
|
476
|
+
}) => (
|
|
477
|
+
<button
|
|
478
|
+
{...attrs}
|
|
479
|
+
aria-label={altText}
|
|
480
|
+
class={classes}
|
|
481
|
+
data-slot="toast-action"
|
|
482
|
+
set:onclick={composeClick(setOnClick, onAction)}
|
|
483
|
+
type={type}
|
|
484
|
+
>
|
|
485
|
+
{children}
|
|
486
|
+
</button>
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
/** Toast close button. */
|
|
490
|
+
const ToastClose: Stateless<ToastCloseArgs> = ({
|
|
491
|
+
children,
|
|
492
|
+
class: classes,
|
|
493
|
+
onClose,
|
|
494
|
+
type = 'button',
|
|
495
|
+
'set:onclick': setOnClick,
|
|
496
|
+
...attrs
|
|
497
|
+
}) => (
|
|
498
|
+
<button
|
|
499
|
+
{...attrs}
|
|
500
|
+
aria-label={attrs['aria-label'] ?? 'Close'}
|
|
501
|
+
class={classes}
|
|
502
|
+
data-slot="toast-close"
|
|
503
|
+
set:onclick={(event: MouseEvent) => {
|
|
504
|
+
const target = event.currentTarget as HTMLElement
|
|
505
|
+
target.closest<HTMLElement>('[data-slot="toast-viewport"]')?.focus()
|
|
506
|
+
composeClick(setOnClick, onClose)(event)
|
|
507
|
+
}}
|
|
508
|
+
type={type}
|
|
509
|
+
>
|
|
510
|
+
{children}
|
|
511
|
+
</button>
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
/** Render generated toasts from the module-level toast store. */
|
|
515
|
+
const Toaster: Stateful<ToasterArgs> = function* ({
|
|
516
|
+
hotkey = hotkeyDefault,
|
|
517
|
+
limit = defaultLimit,
|
|
518
|
+
pauseOnWindowBlur = true,
|
|
519
|
+
}) {
|
|
520
|
+
let rootViewport: HTMLOListElement | null = null
|
|
521
|
+
let portalViewport: HTMLOListElement | null = null
|
|
522
|
+
let portalMount: HTMLElement | null = null
|
|
523
|
+
let portalWatch: MutationObserver | null = null
|
|
524
|
+
let renderPortal: ((items: ToastRecord[]) => void) | null = null
|
|
525
|
+
const modals: HTMLDialogElement[] = []
|
|
526
|
+
let toasts = visibleToasts(toNumber(limit, defaultLimit))
|
|
527
|
+
let currentHotkey: string[] = hotkeyValue(hotkey)
|
|
528
|
+
let currentPauseOnWindowBlur = booleanValue(pauseOnWindowBlur, true)
|
|
529
|
+
let hovered = false
|
|
530
|
+
const heights = new Map<string, number>()
|
|
531
|
+
|
|
532
|
+
// While a modal dialog hosts the portal, the toasts live there: the
|
|
533
|
+
// active viewport is wherever they currently render.
|
|
534
|
+
const viewport = () => portalMount ? portalViewport : rootViewport
|
|
535
|
+
|
|
536
|
+
const measure = () => {
|
|
537
|
+
const host = viewport()
|
|
538
|
+
if (!host) return
|
|
539
|
+
const known = new Set(records.map(record => record.id))
|
|
540
|
+
let changed = false
|
|
541
|
+
for (const key of Array.from(heights.keys())) {
|
|
542
|
+
if (!known.has(key)) heights.delete(key)
|
|
543
|
+
}
|
|
544
|
+
for (const element of Array.from(host.querySelectorAll<HTMLElement>('[data-slot="toast"]'))) {
|
|
545
|
+
const id = element.dataset.toastId
|
|
546
|
+
if (!id || !known.has(id)) continue
|
|
547
|
+
const height = element.offsetHeight
|
|
548
|
+
if (heights.get(id) !== height) {
|
|
549
|
+
heights.set(id, height)
|
|
550
|
+
changed = true
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (changed) this.next()
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Viewport top-layer lifecycle: shown from mount onward, empty included.
|
|
557
|
+
// The viewport is the polite live region, and screen readers only announce
|
|
558
|
+
// content ADDED to a region that already exists in the accessibility tree —
|
|
559
|
+
// a region that first appears together with its content stays silent, so
|
|
560
|
+
// hiding while empty would swallow the first role=status toast of every
|
|
561
|
+
// stack. A shown-but-empty viewport is harmless (pointer-events-none);
|
|
562
|
+
// hide+show happens ONLY for the epoch re-promotion below.
|
|
563
|
+
// openPopover/closePopover guard missing support, repeat calls, and
|
|
564
|
+
// InvalidStateError.
|
|
565
|
+
//
|
|
566
|
+
// Modal nuance, verified against Chromium: "hide all popovers" only pops
|
|
567
|
+
// the auto stack, so showModal() leaves a shown manual viewport open but
|
|
568
|
+
// BELOW the later-promoted <dialog> (top-layer order is promotion order).
|
|
569
|
+
// The epochs track modals promoted after our last show; hide+show in the
|
|
570
|
+
// same task (no paint in between) moves the viewport back above. Gating
|
|
571
|
+
// on the epoch keeps re-promotion to once per modal, so ordinary emits do
|
|
572
|
+
// not flip display and replay the toasts' @starting-style enter
|
|
573
|
+
// transitions.
|
|
574
|
+
let topLayerEpoch = 0
|
|
575
|
+
let promotedEpoch = 0
|
|
576
|
+
|
|
577
|
+
const syncViewport = () => {
|
|
578
|
+
if (!rootViewport?.isConnected) return
|
|
579
|
+
if (promotedEpoch < topLayerEpoch) closePopover(rootViewport)
|
|
580
|
+
openPopover(rootViewport)
|
|
581
|
+
promotedEpoch = topLayerEpoch
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const syncPortal = () => {
|
|
585
|
+
if (portalViewport?.isConnected) openPopover(portalViewport)
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Foreign hides (an engine hiding popovers on showModal, or an outside
|
|
589
|
+
// hidePopover call) re-show: the live region must stay present. Our own
|
|
590
|
+
// hide only happens during epoch re-promotion, which reopens synchronously
|
|
591
|
+
// before this microtask runs, so neither can loop. Deferred to a microtask
|
|
592
|
+
// so the re-show lands after the hide settles. Tearing a portal viewport
|
|
593
|
+
// down never lands here: removal-triggered popover hides fire no toggle.
|
|
594
|
+
const onViewportToggle = (event: ToggleEvent) => {
|
|
595
|
+
if (event.newState !== 'closed') return
|
|
596
|
+
const target = event.currentTarget as HTMLOListElement
|
|
597
|
+
queueMicrotask(() => {
|
|
598
|
+
if (target === portalViewport) syncPortal()
|
|
599
|
+
else if (target === rootViewport) syncViewport()
|
|
600
|
+
})
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const pauseStack = () => {
|
|
604
|
+
for (const record of records) pauseRecord(record)
|
|
605
|
+
}
|
|
606
|
+
const resumeStack = () => {
|
|
607
|
+
for (const record of records) resumeRecord(record)
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
// Portal root: while a modal dialog exposing a portal outlet is open, the
|
|
611
|
+
// toasts render through a second ajo root inside the dialog subtree — the
|
|
612
|
+
// only place the platform lets them stay interactive: everything outside
|
|
613
|
+
// a modal is inert no matter how it stacks, so root-rendered toasts paint
|
|
614
|
+
// above the dialog but never win a hit test (clicks fall through to the
|
|
615
|
+
// backdrop and light-dismiss the dialog), and their live region is pruned
|
|
616
|
+
// from the accessibility tree. The outlet carries `skip`, so the dialog's
|
|
617
|
+
// own tree never reconciles the portal DOM. The root viewport meanwhile
|
|
618
|
+
// stays shown-but-empty, keeping its polite live region alive for the
|
|
619
|
+
// next root-rendered toast. Modals without an outlet (raw dialogs) keep
|
|
620
|
+
// the old visible-but-inert behavior.
|
|
621
|
+
const portalOutlet = (dialog: HTMLDialogElement) =>
|
|
622
|
+
Array.from(dialog.querySelectorAll<HTMLElement>('[data-slot="dialog-portal"]'))
|
|
623
|
+
.find(element => element.closest('dialog') === dialog) ?? null
|
|
624
|
+
|
|
625
|
+
// Only the TOPMOST open modal decides: with stacked modals everything
|
|
626
|
+
// below the top one is inert too, so falling through to a lower outlet
|
|
627
|
+
// would trap the toasts inert and painted under the topmost backdrop.
|
|
628
|
+
// A topmost modal without an outlet returns null — the toasts fall back
|
|
629
|
+
// to the root viewport (visible-but-inert above the modal).
|
|
630
|
+
const portalTarget = () => {
|
|
631
|
+
for (let index = modals.length - 1; index >= 0; index--) {
|
|
632
|
+
const dialog = modals[index]
|
|
633
|
+
if (!dialog.isConnected || !dialog.open) {
|
|
634
|
+
modals.splice(index, 1)
|
|
635
|
+
continue
|
|
636
|
+
}
|
|
637
|
+
return portalOutlet(dialog)
|
|
638
|
+
}
|
|
639
|
+
return null
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
// A tracked dialog can leave the DOM without a toggle the document
|
|
643
|
+
// listener can see (a dialog closed and removed in the same task fires
|
|
644
|
+
// its toggle disconnected, and an unmounted-while-open dialog fires
|
|
645
|
+
// nothing); watch for disconnections while any modal is tracked, or the
|
|
646
|
+
// toasts stay stranded until the next store emit.
|
|
647
|
+
const syncWatch = () => {
|
|
648
|
+
const wanted = Boolean(portalMount) || modals.length > 0
|
|
649
|
+
if (wanted && !portalWatch) {
|
|
650
|
+
portalWatch = new MutationObserver(() => {
|
|
651
|
+
if ((portalMount && !portalMount.isConnected) || modals.some(dialog => !dialog.isConnected)) rehome()
|
|
652
|
+
})
|
|
653
|
+
portalWatch.observe(document.documentElement, { childList: true, subtree: true })
|
|
654
|
+
} else if (!wanted && portalWatch) {
|
|
655
|
+
portalWatch.disconnect()
|
|
656
|
+
portalWatch = null
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
const rehome = () => {
|
|
661
|
+
const outlet = portalTarget()
|
|
662
|
+
if (outlet !== (portalMount?.parentElement ?? null)) {
|
|
663
|
+
if (portalMount) {
|
|
664
|
+
render(null, portalMount)
|
|
665
|
+
portalMount.remove()
|
|
666
|
+
portalMount = null
|
|
667
|
+
portalViewport = null
|
|
668
|
+
}
|
|
669
|
+
if (outlet) {
|
|
670
|
+
portalMount = document.createElement('div')
|
|
671
|
+
portalMount.style.display = 'contents'
|
|
672
|
+
outlet.append(portalMount)
|
|
673
|
+
// Two-phase mount: show the EMPTY portal region now so a
|
|
674
|
+
// toast arriving in the upcoming render is an addition to an
|
|
675
|
+
// existing live region — a region that first appears together
|
|
676
|
+
// with its content stays silent.
|
|
677
|
+
renderPortal?.([])
|
|
678
|
+
}
|
|
679
|
+
// The pointer's hover chain broke with the swap; resume and let
|
|
680
|
+
// a fresh pointerenter on the new viewport re-pause.
|
|
681
|
+
if (hovered) {
|
|
682
|
+
hovered = false
|
|
683
|
+
resumeStack()
|
|
684
|
+
}
|
|
685
|
+
this.next()
|
|
686
|
+
}
|
|
687
|
+
syncWatch()
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
// Modal dialogs fire toggle events on open and close; capture them
|
|
691
|
+
// document-wide (toggle does not bubble). A modal opening AFTER the
|
|
692
|
+
// viewport was shown re-promotes it, and a modal exposing a portal
|
|
693
|
+
// outlet re-homes the toasts into its subtree. Non-modal show() never
|
|
694
|
+
// enters the top layer, so it neither bumps the epoch nor hosts toasts.
|
|
695
|
+
const onTopLayerToggle = (event: Event) => {
|
|
696
|
+
const target = event.target as Element | null
|
|
697
|
+
if (!(target instanceof HTMLDialogElement)) return
|
|
698
|
+
const state = (event as ToggleEvent).newState
|
|
699
|
+
if (state === 'open' && target.matches(':modal')) {
|
|
700
|
+
topLayerEpoch++
|
|
701
|
+
if (!modals.includes(target)) modals.push(target)
|
|
702
|
+
queueMicrotask(() => {
|
|
703
|
+
syncViewport()
|
|
704
|
+
rehome()
|
|
705
|
+
})
|
|
706
|
+
} else if (state === 'closed' && modals.includes(target)) {
|
|
707
|
+
modals.splice(modals.indexOf(target), 1)
|
|
708
|
+
queueMicrotask(rehome)
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
const sync = () => this.next()
|
|
713
|
+
const unsubscribe = subscribeToasts(sync)
|
|
714
|
+
this.signal.addEventListener('abort', () => {
|
|
715
|
+
unsubscribe()
|
|
716
|
+
portalWatch?.disconnect()
|
|
717
|
+
portalWatch = null
|
|
718
|
+
if (portalMount) {
|
|
719
|
+
render(null, portalMount)
|
|
720
|
+
portalMount.remove()
|
|
721
|
+
portalMount = null
|
|
722
|
+
portalViewport = null
|
|
723
|
+
}
|
|
724
|
+
}, { once: true })
|
|
725
|
+
|
|
726
|
+
const focusViewport = (_event: KeyboardEvent) => viewport()?.focus()
|
|
727
|
+
|
|
728
|
+
const pauseAll = () => {
|
|
729
|
+
if (!currentPauseOnWindowBlur) return
|
|
730
|
+
for (const record of records) pauseRecord(record)
|
|
731
|
+
}
|
|
732
|
+
const resumeAll = () => {
|
|
733
|
+
if (!currentPauseOnWindowBlur) return
|
|
734
|
+
for (const record of records) resumeRecord(record)
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
bindHotkey(this, {
|
|
738
|
+
keys: () => currentHotkey.join('+'),
|
|
739
|
+
active: () => toasts.length > 0,
|
|
740
|
+
onPress: event => focusViewport(event),
|
|
741
|
+
})
|
|
742
|
+
|
|
743
|
+
if (browser()) {
|
|
744
|
+
window.addEventListener('blur', pauseAll, { signal: this.signal })
|
|
745
|
+
window.addEventListener('focus', resumeAll, { signal: this.signal })
|
|
746
|
+
document.addEventListener('toggle', onTopLayerToggle, { capture: true, signal: this.signal })
|
|
747
|
+
// Modals already open before this Toaster mounted never fire a toggle
|
|
748
|
+
// we can see; seed them so the first render can portal into them.
|
|
749
|
+
for (const dialog of Array.from(document.querySelectorAll<HTMLDialogElement>('dialog'))) {
|
|
750
|
+
if (dialog.matches(':modal')) modals.push(dialog)
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const {
|
|
755
|
+
actionWrapperClass,
|
|
756
|
+
class: classes,
|
|
757
|
+
closeButton = true,
|
|
758
|
+
closeChildren,
|
|
759
|
+
closeClass,
|
|
760
|
+
contentClass,
|
|
761
|
+
descriptionClass,
|
|
762
|
+
duration = defaultDuration,
|
|
763
|
+
expand = false,
|
|
764
|
+
hotkey = hotkeyDefault,
|
|
765
|
+
label,
|
|
766
|
+
limit = defaultLimit,
|
|
767
|
+
pauseOnWindowBlur = true,
|
|
768
|
+
position = 'bottom-right',
|
|
769
|
+
titleClass,
|
|
770
|
+
toastClass,
|
|
771
|
+
...attrs
|
|
772
|
+
} of this) {
|
|
773
|
+
const nextClass = stringValue(classes)
|
|
774
|
+
const nextCloseButton = booleanValue(closeButton, true)
|
|
775
|
+
const nextDuration = toNumber(duration, defaultDuration)
|
|
776
|
+
const nextHotkey = hotkeyValue(hotkey)
|
|
777
|
+
const nextLabel = stringValue(label) ?? configuredLabel
|
|
778
|
+
const nextLimit = toNumber(limit, defaultLimit)
|
|
779
|
+
const nextPauseOnWindowBlur = booleanValue(pauseOnWindowBlur, true)
|
|
780
|
+
const nextPosition = positionValue(position)
|
|
781
|
+
|
|
782
|
+
configuredDuration = nextDuration
|
|
783
|
+
configuredPosition = nextPosition
|
|
784
|
+
currentHotkey = nextHotkey
|
|
785
|
+
currentPauseOnWindowBlur = nextPauseOnWindowBlur
|
|
786
|
+
toasts = visibleToasts(nextLimit, nextPosition)
|
|
787
|
+
|
|
788
|
+
// A toast fired while the pointer rests on the stack starts its timer
|
|
789
|
+
// (pointerenter cannot re-fire under a stationary pointer); re-pause
|
|
790
|
+
// here so the newcomer freezes with the rest. pauseRecord is idempotent.
|
|
791
|
+
if (hovered) pauseStack()
|
|
792
|
+
|
|
793
|
+
const bottom = nextPosition.startsWith('bottom')
|
|
794
|
+
const lift = bottom ? -1 : 1
|
|
795
|
+
const expanded = booleanValue(expand, false) || hovered
|
|
796
|
+
const frontHeight = heights.get(toasts[0]?.id ?? '') ?? 0
|
|
797
|
+
const openToasts = toasts.filter(item => item.open)
|
|
798
|
+
const stackHeight = edgeInset + (expanded
|
|
799
|
+
? openToasts.reduce((total, item) => total + (heights.get(item.id) ?? 0), 0) + Math.max(0, openToasts.length - 1) * stackGap
|
|
800
|
+
: frontHeight)
|
|
801
|
+
|
|
802
|
+
// One builder, two roots: the yielded tree renders the root viewport
|
|
803
|
+
// (empty while a portal hosts the toasts), and the portal root renders
|
|
804
|
+
// the same view inside the open modal's outlet.
|
|
805
|
+
const view = (items: typeof toasts, portal: boolean) => (
|
|
806
|
+
<ToastViewport
|
|
807
|
+
{...attrs}
|
|
808
|
+
class={nextClass}
|
|
809
|
+
data-portal={portal ? 'true' : undefined}
|
|
810
|
+
hotkey={nextHotkey}
|
|
811
|
+
// A caller-passed id must not duplicate across the two
|
|
812
|
+
// simultaneously mounted viewports; the root keeps it.
|
|
813
|
+
id={portal ? undefined : attrs.id}
|
|
814
|
+
label={nextLabel}
|
|
815
|
+
position={nextPosition}
|
|
816
|
+
ref={(element: HTMLOListElement | null) => {
|
|
817
|
+
if (portal) portalViewport = element
|
|
818
|
+
else rootViewport = element
|
|
819
|
+
}}
|
|
820
|
+
style={`--front-toast-height:${frontHeight}px;--toast-gap:${stackGap}px;height:${stackHeight}px`}
|
|
821
|
+
set:ontoggle={onViewportToggle}
|
|
822
|
+
set:onpointerenter={() => {
|
|
823
|
+
if (hovered) return
|
|
824
|
+
hovered = true
|
|
825
|
+
pauseStack()
|
|
826
|
+
this.next()
|
|
827
|
+
}}
|
|
828
|
+
set:onpointerleave={() => {
|
|
829
|
+
if (!hovered) return
|
|
830
|
+
hovered = false
|
|
831
|
+
resumeStack()
|
|
832
|
+
this.next()
|
|
833
|
+
}}
|
|
834
|
+
>
|
|
835
|
+
{items.map((item, index) => {
|
|
836
|
+
const depth = Math.min(index, 2)
|
|
837
|
+
const offset = expanded
|
|
838
|
+
? items.slice(0, index).reduce((total, prior) => prior.open ? total + (heights.get(prior.id) ?? 0) + stackGap : total, 0)
|
|
839
|
+
: depth * 14
|
|
840
|
+
|
|
841
|
+
return (
|
|
842
|
+
<Toast
|
|
843
|
+
class={clx(resolveClass(toastClass, item), item.class)}
|
|
844
|
+
data-closing={item.open ? 'false' : 'true'}
|
|
845
|
+
data-expanded={expanded ? 'true' : 'false'}
|
|
846
|
+
data-front={index === 0 ? 'true' : 'false'}
|
|
847
|
+
data-side={bottom ? 'bottom' : 'top'}
|
|
848
|
+
data-toast-id={item.id}
|
|
849
|
+
data-toast-index={index}
|
|
850
|
+
key={item.id}
|
|
851
|
+
role={item.role}
|
|
852
|
+
set:onfocusin={() => pauseRecord(item)}
|
|
853
|
+
set:onfocusout={() => resumeRecord(item)}
|
|
854
|
+
set:onpointerenter={() => pauseRecord(item)}
|
|
855
|
+
set:onpointerleave={() => {
|
|
856
|
+
if (!hovered) resumeRecord(item)
|
|
857
|
+
}}
|
|
858
|
+
style={[
|
|
859
|
+
`--toast-y:${lift * offset}px`,
|
|
860
|
+
`--toast-scale:${expanded ? 1 : 1 - depth * 0.04}`,
|
|
861
|
+
`z-index:${100 - index}`,
|
|
862
|
+
].join(';')}
|
|
863
|
+
type={item.type}
|
|
864
|
+
variant={item.variant}
|
|
865
|
+
>
|
|
866
|
+
<div class={contentClass} data-slot="toast-content">
|
|
867
|
+
{item.title == null ? null : <ToastTitle class={titleClass}>{item.title}</ToastTitle>}
|
|
868
|
+
{item.description == null ? null : <ToastDescription class={descriptionClass}>{item.description}</ToastDescription>}
|
|
869
|
+
</div>
|
|
870
|
+
{item.action == null ? null : (
|
|
871
|
+
<div class={actionWrapperClass} data-slot="toast-action-wrapper">
|
|
872
|
+
{item.action}
|
|
873
|
+
</div>
|
|
874
|
+
)}
|
|
875
|
+
{(item.closeButton ?? nextCloseButton) ? (
|
|
876
|
+
<ToastClose class={closeClass} onClose={() => dismissToast(item.id)}>
|
|
877
|
+
{closeChildren}
|
|
878
|
+
</ToastClose>
|
|
879
|
+
) : null}
|
|
880
|
+
</Toast>
|
|
881
|
+
)
|
|
882
|
+
})}
|
|
883
|
+
</ToastViewport>
|
|
884
|
+
)
|
|
885
|
+
|
|
886
|
+
renderPortal = items => {
|
|
887
|
+
if (!portalMount?.isConnected) return
|
|
888
|
+
render(view(items, true), portalMount)
|
|
889
|
+
syncPortal()
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
// Show before measuring: a hidden popover viewport measures 0. The
|
|
893
|
+
// portal render rides the same microtask, so the second root never
|
|
894
|
+
// mounts from inside another component's render pass. A portal whose
|
|
895
|
+
// host dialog vanished without a close event re-homes here, and a
|
|
896
|
+
// modal seeded at mount (opened before this Toaster existed) gets
|
|
897
|
+
// its portal on the first render.
|
|
898
|
+
if (browser()) queueMicrotask(() => {
|
|
899
|
+
if (portalMount?.isConnected) {
|
|
900
|
+
renderPortal?.(toasts)
|
|
901
|
+
} else if (portalMount || (modals.length && portalTarget())) {
|
|
902
|
+
rehome()
|
|
903
|
+
}
|
|
904
|
+
syncViewport()
|
|
905
|
+
measure()
|
|
906
|
+
})
|
|
907
|
+
|
|
908
|
+
yield view(portalMount ? [] : toasts, false)
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
Toaster.attrs = { 'data-slot': 'toaster' }
|
|
913
|
+
|
|
914
|
+
export {
|
|
915
|
+
Toaster,
|
|
916
|
+
Toast,
|
|
917
|
+
ToastAction,
|
|
918
|
+
ToastClose,
|
|
919
|
+
ToastDescription,
|
|
920
|
+
ToastProvider,
|
|
921
|
+
ToastTitle,
|
|
922
|
+
ToastViewport,
|
|
923
|
+
}
|