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.
Files changed (103) hide show
  1. package/LICENSE +15 -0
  2. package/README.md +126 -0
  3. package/dist/accordion.js +130 -0
  4. package/dist/avatar.js +54 -0
  5. package/dist/calendar.js +2 -0
  6. package/dist/carousel.js +239 -0
  7. package/dist/chart.js +790 -0
  8. package/dist/checkbox-group.js +70 -0
  9. package/dist/checkbox.js +77 -0
  10. package/dist/chunks/bar-CVafh6C1.js +99 -0
  11. package/dist/chunks/calendar-q8jZ8Cxr.js +1923 -0
  12. package/dist/chunks/collection-DtRB63U4.js +111 -0
  13. package/dist/chunks/data-table-DpkWv4y4.js +1039 -0
  14. package/dist/chunks/menu-B45IyHHC.js +704 -0
  15. package/dist/chunks/native-BJdhd9XJ.js +20 -0
  16. package/dist/chunks/popup-C8Bb3l_g.js +459 -0
  17. package/dist/chunks/popup-surface-BDCgtVU0.js +18 -0
  18. package/dist/chunks/position-D6_i_SRn.js +434 -0
  19. package/dist/chunks/virtual-list-B7hjGkjk.js +413 -0
  20. package/dist/collapsible.js +106 -0
  21. package/dist/command.js +263 -0
  22. package/dist/context-menu.js +112 -0
  23. package/dist/data-table.js +5 -0
  24. package/dist/dialog.js +207 -0
  25. package/dist/direction.js +22 -0
  26. package/dist/drawer.js +139 -0
  27. package/dist/field.js +26 -0
  28. package/dist/index.js +38 -0
  29. package/dist/input-date.js +994 -0
  30. package/dist/input-group.js +52 -0
  31. package/dist/input-otp.js +179 -0
  32. package/dist/menu.js +2 -0
  33. package/dist/menubar.js +236 -0
  34. package/dist/message-scroller.js +446 -0
  35. package/dist/navigation-menu.js +330 -0
  36. package/dist/popover.js +307 -0
  37. package/dist/progress.js +39 -0
  38. package/dist/radio-group.js +107 -0
  39. package/dist/resizable.js +172 -0
  40. package/dist/select.js +961 -0
  41. package/dist/sidebar.js +343 -0
  42. package/dist/slider.js +259 -0
  43. package/dist/switch.js +53 -0
  44. package/dist/tabs.js +182 -0
  45. package/dist/toast.js +492 -0
  46. package/dist/toggle-group.js +111 -0
  47. package/dist/toggle.js +52 -0
  48. package/dist/toolbar.js +127 -0
  49. package/dist/tooltip.js +196 -0
  50. package/dist/utils.js +104 -0
  51. package/dist/virtual-list.js +2 -0
  52. package/package.json +250 -0
  53. package/src/accordion.tsx +261 -0
  54. package/src/availability.ts +261 -0
  55. package/src/avatar.tsx +99 -0
  56. package/src/bar.ts +156 -0
  57. package/src/calendar.tsx +1441 -0
  58. package/src/carousel.tsx +424 -0
  59. package/src/chart.tsx +1194 -0
  60. package/src/checkbox-group.tsx +132 -0
  61. package/src/checkbox.tsx +130 -0
  62. package/src/collapsible.tsx +188 -0
  63. package/src/collection.ts +154 -0
  64. package/src/command.tsx +511 -0
  65. package/src/context-menu.tsx +233 -0
  66. package/src/data-table-contract.ts +143 -0
  67. package/src/data-table-model.ts +760 -0
  68. package/src/data-table.tsx +475 -0
  69. package/src/dialog.tsx +393 -0
  70. package/src/direction.tsx +45 -0
  71. package/src/drawer.tsx +251 -0
  72. package/src/field.tsx +61 -0
  73. package/src/index.ts +37 -0
  74. package/src/input-date.tsx +1539 -0
  75. package/src/input-group.tsx +142 -0
  76. package/src/input-otp.tsx +324 -0
  77. package/src/menu-cluster.ts +124 -0
  78. package/src/menu.tsx +1095 -0
  79. package/src/menubar.tsx +459 -0
  80. package/src/message-scroller.tsx +732 -0
  81. package/src/native.ts +26 -0
  82. package/src/navigation-menu.tsx +578 -0
  83. package/src/popover.tsx +519 -0
  84. package/src/popup-surface.tsx +31 -0
  85. package/src/popup.ts +569 -0
  86. package/src/position.ts +523 -0
  87. package/src/progress.tsx +70 -0
  88. package/src/radio-group.tsx +186 -0
  89. package/src/resizable.tsx +310 -0
  90. package/src/segments.ts +922 -0
  91. package/src/select.tsx +1501 -0
  92. package/src/sidebar.tsx +683 -0
  93. package/src/slider.tsx +424 -0
  94. package/src/switch.tsx +104 -0
  95. package/src/tabs.tsx +314 -0
  96. package/src/toast.tsx +923 -0
  97. package/src/toggle-group.tsx +249 -0
  98. package/src/toggle.tsx +91 -0
  99. package/src/toolbar.tsx +212 -0
  100. package/src/tooltip.tsx +359 -0
  101. package/src/utils.ts +204 -0
  102. package/src/virtual-list.tsx +205 -0
  103. 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
+ }