@svgrid/grid 2.2.25 → 2.2.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +199 -145
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +179 -179
- package/dist/GridMenus.svelte +734 -728
- package/dist/SvAutoComplete.svelte +165 -165
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +101 -101
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +275 -275
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +160 -160
- package/dist/SvDrawer.svelte +250 -250
- package/dist/SvDropDownList.svelte +374 -374
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.css +2723 -2719
- package/dist/SvGrid.svelte +3333 -3333
- package/dist/SvGridChart.svelte +1724 -1724
- package/dist/SvGridChartPanel.svelte +485 -485
- package/dist/SvGridChartView.svelte +70 -70
- package/dist/SvGridDropdown.svelte +696 -696
- package/dist/SvGridSelect.svelte +259 -259
- package/dist/SvGroupCell.svelte +109 -109
- package/dist/SvListBox.svelte +287 -287
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +281 -281
- package/dist/SvNumberInput.svelte +168 -168
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +131 -131
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRowGroupPanel.svelte +170 -170
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +200 -200
- package/dist/SvSwitchButton.svelte +107 -107
- package/dist/SvTagsInput.svelte +112 -112
- package/dist/SvTextInput.svelte +147 -147
- package/dist/SvTimePicker.svelte +245 -245
- package/dist/SvToaster.svelte +159 -159
- package/dist/SvToggleButton.svelte +85 -85
- package/dist/SvTooltip.svelte +161 -161
- package/dist/SvTour.svelte +204 -204
- package/dist/SvTree.svelte +437 -437
- package/dist/SvTreeSelect.svelte +233 -233
- package/dist/a11y/dismissable.d.ts +11 -0
- package/dist/a11y/dismissable.js +21 -0
- package/dist/ai.js +3 -3
- package/dist/cdn/GridMenus-BzxEYNtW.js +421 -0
- package/dist/cdn/GridMenus-Dr-nGEXn.js +417 -0
- package/dist/cdn/{SvDateTimePicker-CYC7gRER.js → SvDateTimePicker-B1sDc9Pz.js} +451 -444
- package/dist/cdn/{SvDateTimePicker-DtQKd4Ns.js → SvDateTimePicker-Bh0FTs1E.js} +114 -107
- package/dist/cdn/{SvGridChart-BhnFdLd0.js → SvGridChart-BKQsO5fZ.js} +1 -1
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Bgk4hDs8.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-DSkHIQDd.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-Cmx9DHAS.js → SvGridChartPanel-DkCc2Z7b.js} +1 -1
- package/dist/cdn/{SvGridChartView-B40DTJSX.js → SvGridChartView-BrbVaELm.js} +1 -1
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-i9rWvSKg.js} +1 -1
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CGxxopQ4.js} +1 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D08sVmAy.js} +1 -1
- package/dist/cdn/{src-DysW9qbk.js → src-Cw8d9U8j.js} +5753 -5753
- package/dist/cdn/{src-bYfr_7aR.js → src-cgs1wcUS.js} +8102 -8102
- package/dist/cdn/svgrid.js +9 -9
- package/dist/cdn/svgrid.svelte-external.js +9 -9
- package/dist/chart-export.js +8 -8
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +30 -13
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +179 -179
- package/src/GridMenus.svelte +734 -728
- package/src/SvAutoComplete.svelte +165 -165
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +101 -101
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +275 -275
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +160 -160
- package/src/SvDrawer.svelte +250 -250
- package/src/SvDropDownList.svelte +374 -374
- package/src/SvDurationInput.svelte +126 -126
- package/src/SvField.svelte +293 -293
- package/src/SvForm.svelte +437 -437
- package/src/SvForm.test.ts +411 -411
- package/src/SvGrid.controller.svelte.ts +3239 -3239
- package/src/SvGrid.css +2723 -2719
- package/src/SvGrid.svelte +3333 -3333
- package/src/SvGrid.types.ts +1613 -1613
- package/src/SvGridChart.svelte +1724 -1724
- package/src/SvGridChartPanel.svelte +485 -485
- package/src/SvGridChartView.svelte +70 -70
- package/src/SvGridDropdown.svelte +696 -696
- package/src/SvGridSelect.svelte +259 -259
- package/src/SvGroupCell.svelte +109 -109
- package/src/SvListBox.svelte +287 -287
- package/src/SvMaskedInput.svelte +122 -122
- package/src/SvMenu.svelte +124 -124
- package/src/SvMenu.test.ts +97 -97
- package/src/SvMenuList.svelte +146 -146
- package/src/SvMultiSelect.svelte +281 -281
- package/src/SvNumberInput.svelte +168 -168
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +131 -131
- package/src/SvPopover.svelte +197 -197
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRowGroupPanel.svelte +170 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +200 -200
- package/src/SvSwitchButton.svelte +107 -107
- package/src/SvTagsInput.svelte +112 -112
- package/src/SvTextInput.svelte +147 -147
- package/src/SvTimePicker.svelte +245 -245
- package/src/SvToaster.svelte +159 -159
- package/src/SvToaster.test.ts +95 -95
- package/src/SvToggleButton.svelte +85 -85
- package/src/SvTooltip.svelte +161 -161
- package/src/SvTour.svelte +204 -204
- package/src/SvTree.svelte +437 -437
- package/src/SvTreeSelect.svelte +233 -233
- package/src/a11y/dismissable.test.ts +119 -95
- package/src/a11y/dismissable.ts +114 -94
- package/src/a11y.contract.test.ts +49 -49
- package/src/a11y.test.ts +59 -59
- package/src/a11y.ts +61 -61
- package/src/ai.test.ts +502 -502
- package/src/ai.ts +1391 -1391
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +895 -895
- package/src/builtin-editors.grid.test.ts +85 -85
- package/src/cell-formatting.ts +169 -169
- package/src/cell-render.ts +469 -469
- package/src/chart-export.ts +201 -201
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.ts +2296 -2296
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1111 -1111
- package/src/createAutocomplete.svelte.ts +132 -132
- package/src/createCombobox.svelte.ts +191 -191
- package/src/createCountryInput.svelte.ts +157 -157
- package/src/createDropdownList.svelte.ts +168 -168
- package/src/createForm.svelte.ts +386 -386
- package/src/createGrid.svelte.ts +42 -42
- package/src/createGrid.test.ts +10 -10
- package/src/createGridState.svelte.ts +17 -17
- package/src/createMenu.svelte.ts +225 -225
- package/src/createPopoverSelect.svelte.ts +213 -213
- package/src/createSlider.svelte.ts +191 -191
- package/src/createTooltip.svelte.ts +144 -144
- package/src/createTree.svelte.ts +319 -319
- package/src/editing.test.ts +859 -859
- package/src/editing.ts +675 -675
- package/src/editor-contract.ts +157 -157
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +598 -598
- package/src/filter-operators.ts +157 -157
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/index.ts +713 -712
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard.test.ts +59 -59
- package/src/keyboard.ts +97 -97
- package/src/list-nav.test.ts +49 -49
- package/src/list-nav.ts +29 -29
- package/src/list-option.test.ts +56 -56
- package/src/list-option.ts +172 -172
- package/src/menus.test.ts +700 -700
- package/src/menus.ts +652 -652
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/render-component.ts +28 -28
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +872 -872
- package/src/selection.test.ts +754 -754
- package/src/selection.ts +600 -600
- package/src/server-data-source.test.ts +289 -289
- package/src/server-data-source.ts +413 -413
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +489 -489
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/svgrid-wrapper.types.ts +524 -524
- package/src/svgrid.behavior.test.ts +706 -706
- package/src/svgrid.charting.test.ts +534 -534
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-menu-scroll.test.ts +102 -0
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.wrapper.test.ts +40 -40
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +215 -215
- package/src/toast-store.svelte.ts +246 -246
- package/src/toast-store.test.ts +147 -147
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-range.test.ts +70 -70
- package/src/ui-selection.test.ts +155 -155
- package/src/ui-tier1.test.ts +142 -142
- package/src/virtual.test.ts +88 -88
- package/src/virtualization/column-virtualizer.test.ts +27 -27
- package/src/virtualization/column-virtualizer.ts +30 -30
- package/src/virtualization/svelte-virtualizer.svelte.ts +26 -26
- package/src/virtualization/types.ts +30 -30
- package/src/virtualization/virtualizer.test.ts +47 -47
- package/src/virtualization/virtualizer.ts +296 -296
- package/themes/ember.css +71 -71
- package/dist/cdn/GridMenus-GfCAHgkZ.js +0 -420
- package/dist/cdn/GridMenus-GiVNSeDU.js +0 -416
package/src/SvToaster.svelte
CHANGED
|
@@ -1,159 +1,159 @@
|
|
|
1
|
-
<script lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* SvToaster - renders the shared toast queue (see `toast-store`). Mount ONCE
|
|
4
|
-
* near the app root; call `toast()` from anywhere to push notifications. The
|
|
5
|
-
* container is an `aria-live` region as a visual backstop, though each toast
|
|
6
|
-
* is also announced through the shared live region by the store.
|
|
7
|
-
*
|
|
8
|
-
* ```svelte
|
|
9
|
-
* <SvToaster position="bottom-right" />
|
|
10
|
-
* ```
|
|
11
|
-
*/
|
|
12
|
-
import { portalToBody } from './popover'
|
|
13
|
-
import { toastStore, dismissToast, pauseToast, resumeToast, type Toast, type ToastAction } from './toast-store.svelte'
|
|
14
|
-
|
|
15
|
-
type Position =
|
|
16
|
-
| 'top-left' | 'top-center' | 'top-right'
|
|
17
|
-
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
18
|
-
|
|
19
|
-
type Props = {
|
|
20
|
-
/** Corner/edge the stack anchors to. Default 'bottom-right'. */
|
|
21
|
-
position?: Position
|
|
22
|
-
/** Max toasts shown at once (older ones stay queued in the store). */
|
|
23
|
-
max?: number
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
let { position = 'bottom-right', max = 5 }: Props = $props()
|
|
27
|
-
|
|
28
|
-
const visible = $derived(toastStore.toasts.slice(-max))
|
|
29
|
-
const icon = (v: Toast['variant']) =>
|
|
30
|
-
v === 'success' ? '✓' : v === 'error' ? '✕' : v === 'warning' ? '⚠' : 'ℹ'
|
|
31
|
-
|
|
32
|
-
// Swipe-to-dismiss: drag a toast sideways past ~60px to dismiss it.
|
|
33
|
-
let drag = $state<{ id: number; startX: number; dx: number } | null>(null)
|
|
34
|
-
function onDown(e: PointerEvent, id: number) {
|
|
35
|
-
// Only the primary button starts a swipe.
|
|
36
|
-
if (e.button !== 0) return
|
|
37
|
-
// Don't hijack presses on the toast's own controls (dismiss X, action /
|
|
38
|
-
// cancel buttons). Capturing the pointer here would redirect the pointerup
|
|
39
|
-
// to the toast and swallow the button's click.
|
|
40
|
-
if (e.target instanceof Element && e.target.closest('button')) return
|
|
41
|
-
drag = { id, startX: e.clientX, dx: 0 }
|
|
42
|
-
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
43
|
-
pauseToast(id)
|
|
44
|
-
}
|
|
45
|
-
function onMove(e: PointerEvent) {
|
|
46
|
-
if (drag) drag = { ...drag, dx: e.clientX - drag.startX }
|
|
47
|
-
}
|
|
48
|
-
function onUp(id: number) {
|
|
49
|
-
if (!drag || drag.id !== id) return
|
|
50
|
-
const dx = drag.dx
|
|
51
|
-
drag = null
|
|
52
|
-
if (Math.abs(dx) > 60) dismissToast(id)
|
|
53
|
-
else resumeToast(id)
|
|
54
|
-
}
|
|
55
|
-
const dragDx = (id: number) => (drag?.id === id ? drag.dx : 0)
|
|
56
|
-
|
|
57
|
-
// Run a toast action, then dismiss the toast unless it opted to stay open.
|
|
58
|
-
function runAction(a: ToastAction, id: number) {
|
|
59
|
-
a.onClick?.(id)
|
|
60
|
-
if (!a.keepOpen) dismissToast(id)
|
|
61
|
-
}
|
|
62
|
-
</script>
|
|
63
|
-
|
|
64
|
-
<div class="sv-toaster sv-toaster--{position}" use:portalToBody role="region" aria-label="Notifications">
|
|
65
|
-
{#each visible as t (t.id)}
|
|
66
|
-
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
67
|
-
<div
|
|
68
|
-
class="sv-toast sv-toast--{t.variant}"
|
|
69
|
-
class:is-dragging={drag?.id === t.id}
|
|
70
|
-
role={t.variant === 'error' || t.variant === 'warning' ? 'alert' : 'status'}
|
|
71
|
-
style:transform={dragDx(t.id) ? `translateX(${dragDx(t.id)}px)` : undefined}
|
|
72
|
-
style:opacity={dragDx(t.id) ? 1 - Math.min(Math.abs(dragDx(t.id)) / 160, 0.7) : undefined}
|
|
73
|
-
onmouseenter={() => pauseToast(t.id)}
|
|
74
|
-
onmouseleave={() => resumeToast(t.id)}
|
|
75
|
-
onfocusin={() => pauseToast(t.id)}
|
|
76
|
-
onfocusout={() => resumeToast(t.id)}
|
|
77
|
-
onpointerdown={(e) => onDown(e, t.id)}
|
|
78
|
-
onpointermove={onMove}
|
|
79
|
-
onpointerup={() => onUp(t.id)}
|
|
80
|
-
onpointercancel={() => onUp(t.id)}
|
|
81
|
-
>
|
|
82
|
-
{#if t.render}
|
|
83
|
-
<div class="sv-toast__content">{@render t.render(t)}</div>
|
|
84
|
-
{:else}
|
|
85
|
-
<span class="sv-toast__icon" aria-hidden="true">{icon(t.variant)}</span>
|
|
86
|
-
<div class="sv-toast__content">
|
|
87
|
-
{#if t.title}<div class="sv-toast__title">{t.title}</div>{/if}
|
|
88
|
-
<div class="sv-toast__msg">{t.message}</div>
|
|
89
|
-
{#if t.action || t.cancel}
|
|
90
|
-
<div class="sv-toast__actions">
|
|
91
|
-
{#if t.cancel}
|
|
92
|
-
<button type="button" class="sv-toast__action sv-toast__action--cancel" onclick={() => runAction(t.cancel!, t.id)}>{t.cancel.label}</button>
|
|
93
|
-
{/if}
|
|
94
|
-
{#if t.action}
|
|
95
|
-
<button type="button" class="sv-toast__action" onclick={() => runAction(t.action!, t.id)}>{t.action.label}</button>
|
|
96
|
-
{/if}
|
|
97
|
-
</div>
|
|
98
|
-
{/if}
|
|
99
|
-
</div>
|
|
100
|
-
{/if}
|
|
101
|
-
{#if t.dismissible}
|
|
102
|
-
<button type="button" class="sv-toast__x" aria-label="Dismiss" onclick={() => dismissToast(t.id)}>×</button>
|
|
103
|
-
{/if}
|
|
104
|
-
</div>
|
|
105
|
-
{/each}
|
|
106
|
-
</div>
|
|
107
|
-
|
|
108
|
-
<style>
|
|
109
|
-
:global(.sv-toaster) {
|
|
110
|
-
position: fixed; z-index: 2147483001; display: flex; flex-direction: column; gap: 8px;
|
|
111
|
-
padding: 16px; max-width: min(92vw, 380px); pointer-events: none;
|
|
112
|
-
}
|
|
113
|
-
:global(.sv-toaster--top-left) { top: 0; left: 0; }
|
|
114
|
-
:global(.sv-toaster--top-center) { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
|
|
115
|
-
:global(.sv-toaster--top-right) { top: 0; right: 0; }
|
|
116
|
-
:global(.sv-toaster--bottom-left) { bottom: 0; left: 0; flex-direction: column-reverse; }
|
|
117
|
-
:global(.sv-toaster--bottom-center) { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
|
|
118
|
-
:global(.sv-toaster--bottom-right) { bottom: 0; right: 0; flex-direction: column-reverse; }
|
|
119
|
-
|
|
120
|
-
:global(.sv-toast) {
|
|
121
|
-
pointer-events: auto;
|
|
122
|
-
display: flex; align-items: flex-start; gap: 10px; width: 100%; box-sizing: border-box;
|
|
123
|
-
padding: 11px 12px; border-radius: 10px; font-size: 13px; line-height: 1.45;
|
|
124
|
-
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
125
|
-
border: 1px solid var(--sg-border, #e2e8f0);
|
|
126
|
-
border-inline-start: 3px solid var(--_accent, var(--sg-accent, #2563eb));
|
|
127
|
-
box-shadow: 0 12px 32px -12px rgba(15, 23, 42, 0.4);
|
|
128
|
-
animation: sv-toast-in 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
129
|
-
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
130
|
-
touch-action: pan-y; /* let vertical scroll through; capture horizontal swipe */
|
|
131
|
-
}
|
|
132
|
-
:global(.sv-toast.is-dragging) { transition: none; cursor: grabbing; user-select: none; }
|
|
133
|
-
@keyframes sv-toast-in { from { opacity: 0; transform: translateY(6px) scale(0.98) } to { opacity: 1 } }
|
|
134
|
-
@media (prefers-reduced-motion: reduce) { :global(.sv-toast) { animation: none } }
|
|
135
|
-
|
|
136
|
-
:global(.sv-toast--success) { --_accent: var(--sg-success, #16a34a); }
|
|
137
|
-
:global(.sv-toast--error) { --_accent: var(--sg-danger, #dc2626); }
|
|
138
|
-
:global(.sv-toast--warning) { --_accent: var(--sg-warning, #f59e0b); }
|
|
139
|
-
:global(.sv-toast--info) { --_accent: var(--sg-accent, #2563eb); }
|
|
140
|
-
|
|
141
|
-
:global(.sv-toast__icon) { flex: none; width: 18px; height: 18px; display: grid; place-items: center; color: var(--_accent); font-weight: 700; }
|
|
142
|
-
:global(.sv-toast__content) { flex: 1; min-width: 0; }
|
|
143
|
-
:global(.sv-toast__title) { font-weight: 650; margin-bottom: 1px; }
|
|
144
|
-
:global(.sv-toast__msg) { color: var(--sg-fg, #0f172a); overflow-wrap: anywhere; }
|
|
145
|
-
:global(.sv-toast__actions) { display: flex; gap: 8px; margin-top: 9px; }
|
|
146
|
-
:global(.sv-toast__action) {
|
|
147
|
-
font: inherit; font-size: 12.5px; font-weight: 600; padding: 4px 11px; border-radius: 6px;
|
|
148
|
-
border: 1px solid var(--_accent, var(--sg-accent, #2563eb)); background: none; color: var(--_accent, var(--sg-accent, #2563eb));
|
|
149
|
-
cursor: pointer;
|
|
150
|
-
}
|
|
151
|
-
:global(.sv-toast__action:hover) { background: color-mix(in srgb, var(--_accent, #2563eb) 12%, transparent); }
|
|
152
|
-
:global(.sv-toast__action--cancel) { border-color: var(--sg-border, #e2e8f0); color: var(--sg-muted, #64748b); }
|
|
153
|
-
:global(.sv-toast__action--cancel:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
154
|
-
:global(.sv-toast__x) {
|
|
155
|
-
flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -2px -2px 0 0;
|
|
156
|
-
background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 5px; font-size: 17px; line-height: 1;
|
|
157
|
-
}
|
|
158
|
-
:global(.sv-toast__x:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
159
|
-
</style>
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* SvToaster - renders the shared toast queue (see `toast-store`). Mount ONCE
|
|
4
|
+
* near the app root; call `toast()` from anywhere to push notifications. The
|
|
5
|
+
* container is an `aria-live` region as a visual backstop, though each toast
|
|
6
|
+
* is also announced through the shared live region by the store.
|
|
7
|
+
*
|
|
8
|
+
* ```svelte
|
|
9
|
+
* <SvToaster position="bottom-right" />
|
|
10
|
+
* ```
|
|
11
|
+
*/
|
|
12
|
+
import { portalToBody } from './popover'
|
|
13
|
+
import { toastStore, dismissToast, pauseToast, resumeToast, type Toast, type ToastAction } from './toast-store.svelte'
|
|
14
|
+
|
|
15
|
+
type Position =
|
|
16
|
+
| 'top-left' | 'top-center' | 'top-right'
|
|
17
|
+
| 'bottom-left' | 'bottom-center' | 'bottom-right'
|
|
18
|
+
|
|
19
|
+
type Props = {
|
|
20
|
+
/** Corner/edge the stack anchors to. Default 'bottom-right'. */
|
|
21
|
+
position?: Position
|
|
22
|
+
/** Max toasts shown at once (older ones stay queued in the store). */
|
|
23
|
+
max?: number
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
let { position = 'bottom-right', max = 5 }: Props = $props()
|
|
27
|
+
|
|
28
|
+
const visible = $derived(toastStore.toasts.slice(-max))
|
|
29
|
+
const icon = (v: Toast['variant']) =>
|
|
30
|
+
v === 'success' ? '✓' : v === 'error' ? '✕' : v === 'warning' ? '⚠' : 'ℹ'
|
|
31
|
+
|
|
32
|
+
// Swipe-to-dismiss: drag a toast sideways past ~60px to dismiss it.
|
|
33
|
+
let drag = $state<{ id: number; startX: number; dx: number } | null>(null)
|
|
34
|
+
function onDown(e: PointerEvent, id: number) {
|
|
35
|
+
// Only the primary button starts a swipe.
|
|
36
|
+
if (e.button !== 0) return
|
|
37
|
+
// Don't hijack presses on the toast's own controls (dismiss X, action /
|
|
38
|
+
// cancel buttons). Capturing the pointer here would redirect the pointerup
|
|
39
|
+
// to the toast and swallow the button's click.
|
|
40
|
+
if (e.target instanceof Element && e.target.closest('button')) return
|
|
41
|
+
drag = { id, startX: e.clientX, dx: 0 }
|
|
42
|
+
;(e.currentTarget as HTMLElement).setPointerCapture?.(e.pointerId)
|
|
43
|
+
pauseToast(id)
|
|
44
|
+
}
|
|
45
|
+
function onMove(e: PointerEvent) {
|
|
46
|
+
if (drag) drag = { ...drag, dx: e.clientX - drag.startX }
|
|
47
|
+
}
|
|
48
|
+
function onUp(id: number) {
|
|
49
|
+
if (!drag || drag.id !== id) return
|
|
50
|
+
const dx = drag.dx
|
|
51
|
+
drag = null
|
|
52
|
+
if (Math.abs(dx) > 60) dismissToast(id)
|
|
53
|
+
else resumeToast(id)
|
|
54
|
+
}
|
|
55
|
+
const dragDx = (id: number) => (drag?.id === id ? drag.dx : 0)
|
|
56
|
+
|
|
57
|
+
// Run a toast action, then dismiss the toast unless it opted to stay open.
|
|
58
|
+
function runAction(a: ToastAction, id: number) {
|
|
59
|
+
a.onClick?.(id)
|
|
60
|
+
if (!a.keepOpen) dismissToast(id)
|
|
61
|
+
}
|
|
62
|
+
</script>
|
|
63
|
+
|
|
64
|
+
<div class="sv-toaster sv-toaster--{position}" use:portalToBody role="region" aria-label="Notifications">
|
|
65
|
+
{#each visible as t (t.id)}
|
|
66
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
67
|
+
<div
|
|
68
|
+
class="sv-toast sv-toast--{t.variant}"
|
|
69
|
+
class:is-dragging={drag?.id === t.id}
|
|
70
|
+
role={t.variant === 'error' || t.variant === 'warning' ? 'alert' : 'status'}
|
|
71
|
+
style:transform={dragDx(t.id) ? `translateX(${dragDx(t.id)}px)` : undefined}
|
|
72
|
+
style:opacity={dragDx(t.id) ? 1 - Math.min(Math.abs(dragDx(t.id)) / 160, 0.7) : undefined}
|
|
73
|
+
onmouseenter={() => pauseToast(t.id)}
|
|
74
|
+
onmouseleave={() => resumeToast(t.id)}
|
|
75
|
+
onfocusin={() => pauseToast(t.id)}
|
|
76
|
+
onfocusout={() => resumeToast(t.id)}
|
|
77
|
+
onpointerdown={(e) => onDown(e, t.id)}
|
|
78
|
+
onpointermove={onMove}
|
|
79
|
+
onpointerup={() => onUp(t.id)}
|
|
80
|
+
onpointercancel={() => onUp(t.id)}
|
|
81
|
+
>
|
|
82
|
+
{#if t.render}
|
|
83
|
+
<div class="sv-toast__content">{@render t.render(t)}</div>
|
|
84
|
+
{:else}
|
|
85
|
+
<span class="sv-toast__icon" aria-hidden="true">{icon(t.variant)}</span>
|
|
86
|
+
<div class="sv-toast__content">
|
|
87
|
+
{#if t.title}<div class="sv-toast__title">{t.title}</div>{/if}
|
|
88
|
+
<div class="sv-toast__msg">{t.message}</div>
|
|
89
|
+
{#if t.action || t.cancel}
|
|
90
|
+
<div class="sv-toast__actions">
|
|
91
|
+
{#if t.cancel}
|
|
92
|
+
<button type="button" class="sv-toast__action sv-toast__action--cancel" onclick={() => runAction(t.cancel!, t.id)}>{t.cancel.label}</button>
|
|
93
|
+
{/if}
|
|
94
|
+
{#if t.action}
|
|
95
|
+
<button type="button" class="sv-toast__action" onclick={() => runAction(t.action!, t.id)}>{t.action.label}</button>
|
|
96
|
+
{/if}
|
|
97
|
+
</div>
|
|
98
|
+
{/if}
|
|
99
|
+
</div>
|
|
100
|
+
{/if}
|
|
101
|
+
{#if t.dismissible}
|
|
102
|
+
<button type="button" class="sv-toast__x" aria-label="Dismiss" onclick={() => dismissToast(t.id)}>×</button>
|
|
103
|
+
{/if}
|
|
104
|
+
</div>
|
|
105
|
+
{/each}
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
:global(.sv-toaster) {
|
|
110
|
+
position: fixed; z-index: 2147483001; display: flex; flex-direction: column; gap: 8px;
|
|
111
|
+
padding: 16px; max-width: min(92vw, 380px); pointer-events: none;
|
|
112
|
+
}
|
|
113
|
+
:global(.sv-toaster--top-left) { top: 0; left: 0; }
|
|
114
|
+
:global(.sv-toaster--top-center) { top: 0; left: 50%; transform: translateX(-50%); align-items: center; }
|
|
115
|
+
:global(.sv-toaster--top-right) { top: 0; right: 0; }
|
|
116
|
+
:global(.sv-toaster--bottom-left) { bottom: 0; left: 0; flex-direction: column-reverse; }
|
|
117
|
+
:global(.sv-toaster--bottom-center) { bottom: 0; left: 50%; transform: translateX(-50%); align-items: center; flex-direction: column-reverse; }
|
|
118
|
+
:global(.sv-toaster--bottom-right) { bottom: 0; right: 0; flex-direction: column-reverse; }
|
|
119
|
+
|
|
120
|
+
:global(.sv-toast) {
|
|
121
|
+
pointer-events: auto;
|
|
122
|
+
display: flex; align-items: flex-start; gap: 10px; width: 100%; box-sizing: border-box;
|
|
123
|
+
padding: 11px 12px; border-radius: 10px; font-size: 13px; line-height: 1.45;
|
|
124
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
125
|
+
border: 1px solid var(--sg-border, #e2e8f0);
|
|
126
|
+
border-inline-start: 3px solid var(--_accent, var(--sg-accent, #2563eb));
|
|
127
|
+
box-shadow: 0 12px 32px -12px rgba(15, 23, 42, 0.4);
|
|
128
|
+
animation: sv-toast-in 0.18s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
129
|
+
transition: transform 0.2s ease, opacity 0.2s ease;
|
|
130
|
+
touch-action: pan-y; /* let vertical scroll through; capture horizontal swipe */
|
|
131
|
+
}
|
|
132
|
+
:global(.sv-toast.is-dragging) { transition: none; cursor: grabbing; user-select: none; }
|
|
133
|
+
@keyframes sv-toast-in { from { opacity: 0; transform: translateY(6px) scale(0.98) } to { opacity: 1 } }
|
|
134
|
+
@media (prefers-reduced-motion: reduce) { :global(.sv-toast) { animation: none } }
|
|
135
|
+
|
|
136
|
+
:global(.sv-toast--success) { --_accent: var(--sg-success, #16a34a); }
|
|
137
|
+
:global(.sv-toast--error) { --_accent: var(--sg-danger, #dc2626); }
|
|
138
|
+
:global(.sv-toast--warning) { --_accent: var(--sg-warning, #f59e0b); }
|
|
139
|
+
:global(.sv-toast--info) { --_accent: var(--sg-accent, #2563eb); }
|
|
140
|
+
|
|
141
|
+
:global(.sv-toast__icon) { flex: none; width: 18px; height: 18px; display: grid; place-items: center; color: var(--_accent); font-weight: 700; }
|
|
142
|
+
:global(.sv-toast__content) { flex: 1; min-width: 0; }
|
|
143
|
+
:global(.sv-toast__title) { font-weight: 650; margin-bottom: 1px; }
|
|
144
|
+
:global(.sv-toast__msg) { color: var(--sg-fg, #0f172a); overflow-wrap: anywhere; }
|
|
145
|
+
:global(.sv-toast__actions) { display: flex; gap: 8px; margin-top: 9px; }
|
|
146
|
+
:global(.sv-toast__action) {
|
|
147
|
+
font: inherit; font-size: 12.5px; font-weight: 600; padding: 4px 11px; border-radius: 6px;
|
|
148
|
+
border: 1px solid var(--_accent, var(--sg-accent, #2563eb)); background: none; color: var(--_accent, var(--sg-accent, #2563eb));
|
|
149
|
+
cursor: pointer;
|
|
150
|
+
}
|
|
151
|
+
:global(.sv-toast__action:hover) { background: color-mix(in srgb, var(--_accent, #2563eb) 12%, transparent); }
|
|
152
|
+
:global(.sv-toast__action--cancel) { border-color: var(--sg-border, #e2e8f0); color: var(--sg-muted, #64748b); }
|
|
153
|
+
:global(.sv-toast__action--cancel:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
154
|
+
:global(.sv-toast__x) {
|
|
155
|
+
flex: none; width: 22px; height: 22px; display: grid; place-items: center; margin: -2px -2px 0 0;
|
|
156
|
+
background: none; border: 0; color: var(--sg-muted, #64748b); cursor: pointer; border-radius: 5px; font-size: 17px; line-height: 1;
|
|
157
|
+
}
|
|
158
|
+
:global(.sv-toast__x:hover) { background: var(--sg-row-hover-bg, #f1f5f9); color: var(--sg-fg, #0f172a); }
|
|
159
|
+
</style>
|
package/src/SvToaster.test.ts
CHANGED
|
@@ -1,95 +1,95 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Component tests for SvToaster: renders the queue, and swipe-to-dismiss.
|
|
3
|
-
*/
|
|
4
|
-
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
-
import { mount, unmount, flushSync } from 'svelte'
|
|
6
|
-
import SvToaster from './SvToaster.svelte'
|
|
7
|
-
import { toast, clearToasts, toastStore } from './toast-store.svelte'
|
|
8
|
-
|
|
9
|
-
function mountToaster() {
|
|
10
|
-
const target = document.createElement('div')
|
|
11
|
-
document.body.appendChild(target)
|
|
12
|
-
const app = mount(SvToaster, { target, props: {} })
|
|
13
|
-
flushSync()
|
|
14
|
-
return { destroy: () => { unmount(app); target.remove() } }
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
const toastEl = () => document.body.querySelector<HTMLElement>('.sv-toast')
|
|
18
|
-
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
clearToasts()
|
|
21
|
-
document.body.innerHTML = ''
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
describe('SvToaster', () => {
|
|
25
|
-
it('renders a queued toast', () => {
|
|
26
|
-
const { destroy } = mountToaster()
|
|
27
|
-
try {
|
|
28
|
-
toast('Saved', { duration: 0 })
|
|
29
|
-
flushSync()
|
|
30
|
-
expect(toastEl()).not.toBeNull()
|
|
31
|
-
expect(toastEl()!.textContent).toContain('Saved')
|
|
32
|
-
} finally { destroy() }
|
|
33
|
-
})
|
|
34
|
-
|
|
35
|
-
it('swiping a toast past the threshold dismisses it', () => {
|
|
36
|
-
const { destroy } = mountToaster()
|
|
37
|
-
try {
|
|
38
|
-
toast('Swipe me', { duration: 0 })
|
|
39
|
-
flushSync()
|
|
40
|
-
const el = toastEl()!
|
|
41
|
-
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100 }))
|
|
42
|
-
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 220 })) // dx 120 > 60
|
|
43
|
-
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 220 }))
|
|
44
|
-
flushSync()
|
|
45
|
-
expect(toastStore.toasts).toHaveLength(0)
|
|
46
|
-
expect(toastEl()).toBeNull()
|
|
47
|
-
} finally { destroy() }
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('clicking the dismiss (x) button removes the toast', () => {
|
|
51
|
-
const { destroy } = mountToaster()
|
|
52
|
-
try {
|
|
53
|
-
toast('Close me', { duration: 0 })
|
|
54
|
-
flushSync()
|
|
55
|
-
const x = document.body.querySelector<HTMLButtonElement>('.sv-toast__x')!
|
|
56
|
-
expect(x).not.toBeNull()
|
|
57
|
-
x.click()
|
|
58
|
-
flushSync()
|
|
59
|
-
expect(toastStore.toasts).toHaveLength(0)
|
|
60
|
-
expect(toastEl()).toBeNull()
|
|
61
|
-
} finally { destroy() }
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('a press that starts on the dismiss button does not begin a swipe', () => {
|
|
65
|
-
// The swipe handler must ignore presses on the toast's own controls,
|
|
66
|
-
// otherwise pointer-capture on the toast swallows the button's click.
|
|
67
|
-
const { destroy } = mountToaster()
|
|
68
|
-
try {
|
|
69
|
-
toast('Keep me', { duration: 0 })
|
|
70
|
-
flushSync()
|
|
71
|
-
const el = toastEl()!
|
|
72
|
-
const x = el.querySelector<HTMLButtonElement>('.sv-toast__x')!
|
|
73
|
-
x.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, button: 0 }))
|
|
74
|
-
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 240 })) // dx 140 > 60
|
|
75
|
-
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 240 }))
|
|
76
|
-
flushSync()
|
|
77
|
-
// No swipe was started, so the toast survives (the click path owns dismiss).
|
|
78
|
-
expect(toastStore.toasts).toHaveLength(1)
|
|
79
|
-
} finally { destroy() }
|
|
80
|
-
})
|
|
81
|
-
|
|
82
|
-
it('a small swipe does not dismiss', () => {
|
|
83
|
-
const { destroy } = mountToaster()
|
|
84
|
-
try {
|
|
85
|
-
toast('Keep me', { duration: 0 })
|
|
86
|
-
flushSync()
|
|
87
|
-
const el = toastEl()!
|
|
88
|
-
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100 }))
|
|
89
|
-
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 130 })) // dx 30 < 60
|
|
90
|
-
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 130 }))
|
|
91
|
-
flushSync()
|
|
92
|
-
expect(toastStore.toasts).toHaveLength(1)
|
|
93
|
-
} finally { destroy() }
|
|
94
|
-
})
|
|
95
|
-
})
|
|
1
|
+
/**
|
|
2
|
+
* Component tests for SvToaster: renders the queue, and swipe-to-dismiss.
|
|
3
|
+
*/
|
|
4
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
5
|
+
import { mount, unmount, flushSync } from 'svelte'
|
|
6
|
+
import SvToaster from './SvToaster.svelte'
|
|
7
|
+
import { toast, clearToasts, toastStore } from './toast-store.svelte'
|
|
8
|
+
|
|
9
|
+
function mountToaster() {
|
|
10
|
+
const target = document.createElement('div')
|
|
11
|
+
document.body.appendChild(target)
|
|
12
|
+
const app = mount(SvToaster, { target, props: {} })
|
|
13
|
+
flushSync()
|
|
14
|
+
return { destroy: () => { unmount(app); target.remove() } }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const toastEl = () => document.body.querySelector<HTMLElement>('.sv-toast')
|
|
18
|
+
|
|
19
|
+
afterEach(() => {
|
|
20
|
+
clearToasts()
|
|
21
|
+
document.body.innerHTML = ''
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
describe('SvToaster', () => {
|
|
25
|
+
it('renders a queued toast', () => {
|
|
26
|
+
const { destroy } = mountToaster()
|
|
27
|
+
try {
|
|
28
|
+
toast('Saved', { duration: 0 })
|
|
29
|
+
flushSync()
|
|
30
|
+
expect(toastEl()).not.toBeNull()
|
|
31
|
+
expect(toastEl()!.textContent).toContain('Saved')
|
|
32
|
+
} finally { destroy() }
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('swiping a toast past the threshold dismisses it', () => {
|
|
36
|
+
const { destroy } = mountToaster()
|
|
37
|
+
try {
|
|
38
|
+
toast('Swipe me', { duration: 0 })
|
|
39
|
+
flushSync()
|
|
40
|
+
const el = toastEl()!
|
|
41
|
+
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100 }))
|
|
42
|
+
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 220 })) // dx 120 > 60
|
|
43
|
+
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 220 }))
|
|
44
|
+
flushSync()
|
|
45
|
+
expect(toastStore.toasts).toHaveLength(0)
|
|
46
|
+
expect(toastEl()).toBeNull()
|
|
47
|
+
} finally { destroy() }
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('clicking the dismiss (x) button removes the toast', () => {
|
|
51
|
+
const { destroy } = mountToaster()
|
|
52
|
+
try {
|
|
53
|
+
toast('Close me', { duration: 0 })
|
|
54
|
+
flushSync()
|
|
55
|
+
const x = document.body.querySelector<HTMLButtonElement>('.sv-toast__x')!
|
|
56
|
+
expect(x).not.toBeNull()
|
|
57
|
+
x.click()
|
|
58
|
+
flushSync()
|
|
59
|
+
expect(toastStore.toasts).toHaveLength(0)
|
|
60
|
+
expect(toastEl()).toBeNull()
|
|
61
|
+
} finally { destroy() }
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('a press that starts on the dismiss button does not begin a swipe', () => {
|
|
65
|
+
// The swipe handler must ignore presses on the toast's own controls,
|
|
66
|
+
// otherwise pointer-capture on the toast swallows the button's click.
|
|
67
|
+
const { destroy } = mountToaster()
|
|
68
|
+
try {
|
|
69
|
+
toast('Keep me', { duration: 0 })
|
|
70
|
+
flushSync()
|
|
71
|
+
const el = toastEl()!
|
|
72
|
+
const x = el.querySelector<HTMLButtonElement>('.sv-toast__x')!
|
|
73
|
+
x.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100, button: 0 }))
|
|
74
|
+
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 240 })) // dx 140 > 60
|
|
75
|
+
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 240 }))
|
|
76
|
+
flushSync()
|
|
77
|
+
// No swipe was started, so the toast survives (the click path owns dismiss).
|
|
78
|
+
expect(toastStore.toasts).toHaveLength(1)
|
|
79
|
+
} finally { destroy() }
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('a small swipe does not dismiss', () => {
|
|
83
|
+
const { destroy } = mountToaster()
|
|
84
|
+
try {
|
|
85
|
+
toast('Keep me', { duration: 0 })
|
|
86
|
+
flushSync()
|
|
87
|
+
const el = toastEl()!
|
|
88
|
+
el.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, clientX: 100 }))
|
|
89
|
+
el.dispatchEvent(new MouseEvent('pointermove', { bubbles: true, clientX: 130 })) // dx 30 < 60
|
|
90
|
+
el.dispatchEvent(new MouseEvent('pointerup', { bubbles: true, clientX: 130 }))
|
|
91
|
+
flushSync()
|
|
92
|
+
expect(toastStore.toasts).toHaveLength(1)
|
|
93
|
+
} finally { destroy() }
|
|
94
|
+
})
|
|
95
|
+
})
|