@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/dist/SvTour.svelte
CHANGED
|
@@ -1,204 +1,204 @@
|
|
|
1
|
-
<script lang="ts" module>
|
|
2
|
-
export type { TourStep } from './ui-app.types'
|
|
3
|
-
</script>
|
|
4
|
-
|
|
5
|
-
<script lang="ts">
|
|
6
|
-
/**
|
|
7
|
-
* SvTour - a guided product tour / onboarding: it spotlights each step's target
|
|
8
|
-
* element (a dimmed backdrop with a cut-out), anchors a popover with the step's
|
|
9
|
-
* title/content near it, and steps Back / Next / Done. Arrow keys navigate,
|
|
10
|
-
* Escape skips. Portalled to <body>. Controlled via `open`.
|
|
11
|
-
*
|
|
12
|
-
* ```svelte
|
|
13
|
-
* <SvTour bind:open steps={[
|
|
14
|
-
* { target: '#new-btn', title: 'Create', content: 'Start here.' },
|
|
15
|
-
* { target: '.grid', title: 'Your data', content: 'It shows up here.' },
|
|
16
|
-
* ]} />
|
|
17
|
-
* ```
|
|
18
|
-
*/
|
|
19
|
-
import type { TourStep } from './ui-app.types'
|
|
20
|
-
import { portalToBody } from './popover'
|
|
21
|
-
import { createFocusTrap } from './a11y/focus-trap'
|
|
22
|
-
import type { EditorDir } from './editor-contract'
|
|
23
|
-
|
|
24
|
-
type Props = {
|
|
25
|
-
steps: ReadonlyArray<TourStep>
|
|
26
|
-
open?: boolean
|
|
27
|
-
onFinish?: () => void
|
|
28
|
-
onSkip?: () => void
|
|
29
|
-
onStep?: (index: number) => void
|
|
30
|
-
/** Text direction; under `'rtl'` the Left/Right advance/back keys swap. */
|
|
31
|
-
dir?: EditorDir
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
let { steps, open = $bindable(false), onFinish, onSkip, onStep, dir }: Props = $props()
|
|
35
|
-
|
|
36
|
-
let index = $state(0)
|
|
37
|
-
let rect = $state<DOMRect | null>(null)
|
|
38
|
-
let popEl = $state<HTMLDivElement | null>(null)
|
|
39
|
-
const step = $derived(steps[index])
|
|
40
|
-
|
|
41
|
-
function targetEl(): HTMLElement | null {
|
|
42
|
-
const t = step?.target
|
|
43
|
-
if (!t) return null
|
|
44
|
-
return typeof t === 'string' ? document.querySelector<HTMLElement>(t) : t
|
|
45
|
-
}
|
|
46
|
-
function measure() {
|
|
47
|
-
const el = targetEl()
|
|
48
|
-
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
49
|
-
rect = el ? el.getBoundingClientRect() : null
|
|
50
|
-
}
|
|
51
|
-
// Move focus into the popover on open + on every step change (its Next button
|
|
52
|
-
// is the sensible default target); the Tab trap (below) keeps it there.
|
|
53
|
-
function focusPopover() {
|
|
54
|
-
const btn = popEl?.querySelector<HTMLElement>('.sv-tour__next')
|
|
55
|
-
;(btn ?? popEl)?.focus()
|
|
56
|
-
}
|
|
57
|
-
function goto(i: number) {
|
|
58
|
-
index = Math.max(0, Math.min(i, steps.length - 1))
|
|
59
|
-
onStep?.(index)
|
|
60
|
-
queueMicrotask(() => { measure(); focusPopover() })
|
|
61
|
-
}
|
|
62
|
-
function next() { if (index >= steps.length - 1) finish(); else goto(index + 1) }
|
|
63
|
-
function back() { goto(index - 1) }
|
|
64
|
-
function finish() { open = false; onFinish?.() }
|
|
65
|
-
function skip() { open = false; onSkip?.() }
|
|
66
|
-
|
|
67
|
-
$effect(() => {
|
|
68
|
-
if (!open) return
|
|
69
|
-
index = 0
|
|
70
|
-
queueMicrotask(() => { measure(); focusPopover() })
|
|
71
|
-
const reflow = () => measure()
|
|
72
|
-
window.addEventListener('resize', reflow)
|
|
73
|
-
window.addEventListener('scroll', reflow, true)
|
|
74
|
-
const rtl = dir === 'rtl'
|
|
75
|
-
const onKey = (e: KeyboardEvent) => {
|
|
76
|
-
if (e.key === 'Escape') { e.preventDefault(); skip() }
|
|
77
|
-
else if (e.key === 'ArrowRight') { e.preventDefault(); (rtl ? back : next)() }
|
|
78
|
-
else if (e.key === 'ArrowLeft') { e.preventDefault(); (rtl ? next : back)() }
|
|
79
|
-
}
|
|
80
|
-
window.addEventListener('keydown', onKey)
|
|
81
|
-
return () => {
|
|
82
|
-
window.removeEventListener('resize', reflow)
|
|
83
|
-
window.removeEventListener('scroll', reflow, true)
|
|
84
|
-
window.removeEventListener('keydown', onKey)
|
|
85
|
-
}
|
|
86
|
-
})
|
|
87
|
-
|
|
88
|
-
// Focus trap for the duration the tour is open, from the shared primitive
|
|
89
|
-
// (same one SvModal/SvDrawer use): traps Tab within the popover and restores
|
|
90
|
-
// focus to whatever triggered the tour once it closes.
|
|
91
|
-
$effect(() => {
|
|
92
|
-
if (!open || !popEl) return
|
|
93
|
-
const trap = createFocusTrap(popEl, {
|
|
94
|
-
initialFocus: () => popEl?.querySelector<HTMLElement>('.sv-tour__next') ?? null,
|
|
95
|
-
})
|
|
96
|
-
trap.activate()
|
|
97
|
-
return () => trap.release()
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
const POP_W = 300
|
|
101
|
-
// Measured popover height, so placement can keep the WHOLE popover on-screen
|
|
102
|
-
// (long content + tall targets used to push it off the top).
|
|
103
|
-
let popH = $state(200)
|
|
104
|
-
const pop = $derived.by(() => {
|
|
105
|
-
if (typeof window === 'undefined' || !rect) {
|
|
106
|
-
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', place: 'center' as const }
|
|
107
|
-
}
|
|
108
|
-
const vw = window.innerWidth
|
|
109
|
-
const vh = window.innerHeight
|
|
110
|
-
const gap = 12
|
|
111
|
-
const h = popH || 200
|
|
112
|
-
const clampTop = (t: number) => Math.max(gap, Math.min(t, vh - h - gap))
|
|
113
|
-
const clampLeft = (l: number) => Math.max(gap, Math.min(l, vw - POP_W - gap))
|
|
114
|
-
// Prefer directly below the target, then directly above - but only when the
|
|
115
|
-
// target is short enough to leave room for the whole popover.
|
|
116
|
-
if (vh - rect.bottom >= h + gap) {
|
|
117
|
-
return { top: `${rect.bottom + 10}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'bottom' as const }
|
|
118
|
-
}
|
|
119
|
-
if (rect.top >= h + gap) {
|
|
120
|
-
return { top: `${clampTop(rect.top - 10 - h)}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'top' as const }
|
|
121
|
-
}
|
|
122
|
-
// Tall target (a full-height rail / canvas / inspector): sit BESIDE it,
|
|
123
|
-
// vertically centred on the target, on whichever side has room.
|
|
124
|
-
const top = clampTop(rect.top + rect.height / 2 - h / 2)
|
|
125
|
-
if (vw - rect.right >= POP_W + gap) return { top: `${top}px`, left: `${rect.right + 10}px`, transform: 'none', place: 'center' as const }
|
|
126
|
-
if (rect.left >= POP_W + gap) return { top: `${top}px`, left: `${rect.left - POP_W - 10}px`, transform: 'none', place: 'center' as const }
|
|
127
|
-
// No side room either - centre horizontally (still fully visible).
|
|
128
|
-
return { top: `${top}px`, left: `${clampLeft((vw - POP_W) / 2)}px`, transform: 'none', place: 'center' as const }
|
|
129
|
-
})
|
|
130
|
-
</script>
|
|
131
|
-
|
|
132
|
-
{#if open && step}
|
|
133
|
-
<div class="sv-tour" use:portalToBody>
|
|
134
|
-
{#if rect}
|
|
135
|
-
<div
|
|
136
|
-
class="sv-tour__spot"
|
|
137
|
-
style:top={`${rect.top - 6}px`}
|
|
138
|
-
style:left={`${rect.left - 6}px`}
|
|
139
|
-
style:width={`${rect.width + 12}px`}
|
|
140
|
-
style:height={`${rect.height + 12}px`}
|
|
141
|
-
></div>
|
|
142
|
-
{:else}
|
|
143
|
-
<div class="sv-tour__backdrop"></div>
|
|
144
|
-
{/if}
|
|
145
|
-
|
|
146
|
-
<div
|
|
147
|
-
bind:this={popEl}
|
|
148
|
-
bind:clientHeight={popH}
|
|
149
|
-
class="sv-tour__pop sv-tour__pop--{pop.place}"
|
|
150
|
-
style:top={pop.top}
|
|
151
|
-
style:left={pop.left}
|
|
152
|
-
style:transform={pop.transform}
|
|
153
|
-
role="dialog"
|
|
154
|
-
aria-modal="true"
|
|
155
|
-
aria-label={step.title ?? 'Tour step'}
|
|
156
|
-
>
|
|
157
|
-
{#if step.image}
|
|
158
|
-
<div class="sv-tour__media">
|
|
159
|
-
{#if /^(https?:|data:|\/)/.test(step.image)}<img class="sv-tour__img" src={step.image} alt="" />{:else}{@html step.image}{/if}
|
|
160
|
-
</div>
|
|
161
|
-
{/if}
|
|
162
|
-
{#if step.title}<div class="sv-tour__title">{step.title}</div>{/if}
|
|
163
|
-
<div class="sv-tour__body">{step.content}</div>
|
|
164
|
-
<div class="sv-tour__foot">
|
|
165
|
-
<span class="sv-tour__count">{index + 1} / {steps.length}</span>
|
|
166
|
-
<div class="sv-tour__btns">
|
|
167
|
-
<button type="button" class="sv-tour__skip" onclick={skip}>Skip</button>
|
|
168
|
-
{#if index > 0}<button type="button" class="sv-tour__back" onclick={back}>Back</button>{/if}
|
|
169
|
-
<button type="button" class="sv-tour__next" onclick={next}>{index === steps.length - 1 ? 'Done' : 'Next'}</button>
|
|
170
|
-
</div>
|
|
171
|
-
</div>
|
|
172
|
-
</div>
|
|
173
|
-
</div>
|
|
174
|
-
{/if}
|
|
175
|
-
|
|
176
|
-
<style>
|
|
177
|
-
:global(.sv-tour) { position: fixed; inset: 0; z-index: 2147483000; }
|
|
178
|
-
:global(.sv-tour__backdrop) { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55); }
|
|
179
|
-
:global(.sv-tour__spot) {
|
|
180
|
-
position: fixed; border-radius: 8px; pointer-events: none;
|
|
181
|
-
box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.55); transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
|
182
|
-
}
|
|
183
|
-
:global(.sv-tour__pop) {
|
|
184
|
-
position: fixed; z-index: 1; width: 300px; max-width: calc(100vw - 24px);
|
|
185
|
-
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
186
|
-
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 12px; padding: 14px 16px;
|
|
187
|
-
box-shadow: 0 20px 48px -16px rgba(15, 23, 42, 0.5);
|
|
188
|
-
animation: sv-tour-in 0.16s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
189
|
-
}
|
|
190
|
-
@keyframes sv-tour-in { from { opacity: 0; transform: translateY(4px) } }
|
|
191
|
-
:global(.sv-tour__pop--top) { animation: none; }
|
|
192
|
-
:global(.sv-tour__media) { margin: -2px -4px 12px; border-radius: 9px; overflow: hidden; line-height: 0; }
|
|
193
|
-
:global(.sv-tour__media svg), :global(.sv-tour__img) { display: block; width: 100%; height: auto; }
|
|
194
|
-
:global(.sv-tour__title) { font-size: 14.5px; font-weight: 650; margin-bottom: 4px; }
|
|
195
|
-
:global(.sv-tour__body) { font-size: 13px; line-height: 1.55; color: var(--sg-fg, #334155); }
|
|
196
|
-
:global(.sv-tour__foot) { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
|
|
197
|
-
:global(.sv-tour__count) { font-size: 12px; color: var(--sg-muted, #94a3b8); font-variant-numeric: tabular-nums; }
|
|
198
|
-
:global(.sv-tour__btns) { display: flex; gap: 6px; }
|
|
199
|
-
:global(.sv-tour__skip), :global(.sv-tour__back), :global(.sv-tour__next) {
|
|
200
|
-
font: inherit; font-size: 12.5px; padding: 5px 12px; border-radius: 7px; cursor: pointer; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
201
|
-
}
|
|
202
|
-
:global(.sv-tour__skip) { border-color: transparent; background: none; color: var(--sg-muted, #64748b); }
|
|
203
|
-
:global(.sv-tour__next) { background: var(--sg-accent, #2563eb); border-color: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff); }
|
|
204
|
-
</style>
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export type { TourStep } from './ui-app.types'
|
|
3
|
+
</script>
|
|
4
|
+
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
/**
|
|
7
|
+
* SvTour - a guided product tour / onboarding: it spotlights each step's target
|
|
8
|
+
* element (a dimmed backdrop with a cut-out), anchors a popover with the step's
|
|
9
|
+
* title/content near it, and steps Back / Next / Done. Arrow keys navigate,
|
|
10
|
+
* Escape skips. Portalled to <body>. Controlled via `open`.
|
|
11
|
+
*
|
|
12
|
+
* ```svelte
|
|
13
|
+
* <SvTour bind:open steps={[
|
|
14
|
+
* { target: '#new-btn', title: 'Create', content: 'Start here.' },
|
|
15
|
+
* { target: '.grid', title: 'Your data', content: 'It shows up here.' },
|
|
16
|
+
* ]} />
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
import type { TourStep } from './ui-app.types'
|
|
20
|
+
import { portalToBody } from './popover'
|
|
21
|
+
import { createFocusTrap } from './a11y/focus-trap'
|
|
22
|
+
import type { EditorDir } from './editor-contract'
|
|
23
|
+
|
|
24
|
+
type Props = {
|
|
25
|
+
steps: ReadonlyArray<TourStep>
|
|
26
|
+
open?: boolean
|
|
27
|
+
onFinish?: () => void
|
|
28
|
+
onSkip?: () => void
|
|
29
|
+
onStep?: (index: number) => void
|
|
30
|
+
/** Text direction; under `'rtl'` the Left/Right advance/back keys swap. */
|
|
31
|
+
dir?: EditorDir
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
let { steps, open = $bindable(false), onFinish, onSkip, onStep, dir }: Props = $props()
|
|
35
|
+
|
|
36
|
+
let index = $state(0)
|
|
37
|
+
let rect = $state<DOMRect | null>(null)
|
|
38
|
+
let popEl = $state<HTMLDivElement | null>(null)
|
|
39
|
+
const step = $derived(steps[index])
|
|
40
|
+
|
|
41
|
+
function targetEl(): HTMLElement | null {
|
|
42
|
+
const t = step?.target
|
|
43
|
+
if (!t) return null
|
|
44
|
+
return typeof t === 'string' ? document.querySelector<HTMLElement>(t) : t
|
|
45
|
+
}
|
|
46
|
+
function measure() {
|
|
47
|
+
const el = targetEl()
|
|
48
|
+
el?.scrollIntoView({ block: 'center', behavior: 'smooth' })
|
|
49
|
+
rect = el ? el.getBoundingClientRect() : null
|
|
50
|
+
}
|
|
51
|
+
// Move focus into the popover on open + on every step change (its Next button
|
|
52
|
+
// is the sensible default target); the Tab trap (below) keeps it there.
|
|
53
|
+
function focusPopover() {
|
|
54
|
+
const btn = popEl?.querySelector<HTMLElement>('.sv-tour__next')
|
|
55
|
+
;(btn ?? popEl)?.focus()
|
|
56
|
+
}
|
|
57
|
+
function goto(i: number) {
|
|
58
|
+
index = Math.max(0, Math.min(i, steps.length - 1))
|
|
59
|
+
onStep?.(index)
|
|
60
|
+
queueMicrotask(() => { measure(); focusPopover() })
|
|
61
|
+
}
|
|
62
|
+
function next() { if (index >= steps.length - 1) finish(); else goto(index + 1) }
|
|
63
|
+
function back() { goto(index - 1) }
|
|
64
|
+
function finish() { open = false; onFinish?.() }
|
|
65
|
+
function skip() { open = false; onSkip?.() }
|
|
66
|
+
|
|
67
|
+
$effect(() => {
|
|
68
|
+
if (!open) return
|
|
69
|
+
index = 0
|
|
70
|
+
queueMicrotask(() => { measure(); focusPopover() })
|
|
71
|
+
const reflow = () => measure()
|
|
72
|
+
window.addEventListener('resize', reflow)
|
|
73
|
+
window.addEventListener('scroll', reflow, true)
|
|
74
|
+
const rtl = dir === 'rtl'
|
|
75
|
+
const onKey = (e: KeyboardEvent) => {
|
|
76
|
+
if (e.key === 'Escape') { e.preventDefault(); skip() }
|
|
77
|
+
else if (e.key === 'ArrowRight') { e.preventDefault(); (rtl ? back : next)() }
|
|
78
|
+
else if (e.key === 'ArrowLeft') { e.preventDefault(); (rtl ? next : back)() }
|
|
79
|
+
}
|
|
80
|
+
window.addEventListener('keydown', onKey)
|
|
81
|
+
return () => {
|
|
82
|
+
window.removeEventListener('resize', reflow)
|
|
83
|
+
window.removeEventListener('scroll', reflow, true)
|
|
84
|
+
window.removeEventListener('keydown', onKey)
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
// Focus trap for the duration the tour is open, from the shared primitive
|
|
89
|
+
// (same one SvModal/SvDrawer use): traps Tab within the popover and restores
|
|
90
|
+
// focus to whatever triggered the tour once it closes.
|
|
91
|
+
$effect(() => {
|
|
92
|
+
if (!open || !popEl) return
|
|
93
|
+
const trap = createFocusTrap(popEl, {
|
|
94
|
+
initialFocus: () => popEl?.querySelector<HTMLElement>('.sv-tour__next') ?? null,
|
|
95
|
+
})
|
|
96
|
+
trap.activate()
|
|
97
|
+
return () => trap.release()
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
const POP_W = 300
|
|
101
|
+
// Measured popover height, so placement can keep the WHOLE popover on-screen
|
|
102
|
+
// (long content + tall targets used to push it off the top).
|
|
103
|
+
let popH = $state(200)
|
|
104
|
+
const pop = $derived.by(() => {
|
|
105
|
+
if (typeof window === 'undefined' || !rect) {
|
|
106
|
+
return { top: '50%', left: '50%', transform: 'translate(-50%, -50%)', place: 'center' as const }
|
|
107
|
+
}
|
|
108
|
+
const vw = window.innerWidth
|
|
109
|
+
const vh = window.innerHeight
|
|
110
|
+
const gap = 12
|
|
111
|
+
const h = popH || 200
|
|
112
|
+
const clampTop = (t: number) => Math.max(gap, Math.min(t, vh - h - gap))
|
|
113
|
+
const clampLeft = (l: number) => Math.max(gap, Math.min(l, vw - POP_W - gap))
|
|
114
|
+
// Prefer directly below the target, then directly above - but only when the
|
|
115
|
+
// target is short enough to leave room for the whole popover.
|
|
116
|
+
if (vh - rect.bottom >= h + gap) {
|
|
117
|
+
return { top: `${rect.bottom + 10}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'bottom' as const }
|
|
118
|
+
}
|
|
119
|
+
if (rect.top >= h + gap) {
|
|
120
|
+
return { top: `${clampTop(rect.top - 10 - h)}px`, left: `${clampLeft(rect.left)}px`, transform: 'none', place: 'top' as const }
|
|
121
|
+
}
|
|
122
|
+
// Tall target (a full-height rail / canvas / inspector): sit BESIDE it,
|
|
123
|
+
// vertically centred on the target, on whichever side has room.
|
|
124
|
+
const top = clampTop(rect.top + rect.height / 2 - h / 2)
|
|
125
|
+
if (vw - rect.right >= POP_W + gap) return { top: `${top}px`, left: `${rect.right + 10}px`, transform: 'none', place: 'center' as const }
|
|
126
|
+
if (rect.left >= POP_W + gap) return { top: `${top}px`, left: `${rect.left - POP_W - 10}px`, transform: 'none', place: 'center' as const }
|
|
127
|
+
// No side room either - centre horizontally (still fully visible).
|
|
128
|
+
return { top: `${top}px`, left: `${clampLeft((vw - POP_W) / 2)}px`, transform: 'none', place: 'center' as const }
|
|
129
|
+
})
|
|
130
|
+
</script>
|
|
131
|
+
|
|
132
|
+
{#if open && step}
|
|
133
|
+
<div class="sv-tour" use:portalToBody>
|
|
134
|
+
{#if rect}
|
|
135
|
+
<div
|
|
136
|
+
class="sv-tour__spot"
|
|
137
|
+
style:top={`${rect.top - 6}px`}
|
|
138
|
+
style:left={`${rect.left - 6}px`}
|
|
139
|
+
style:width={`${rect.width + 12}px`}
|
|
140
|
+
style:height={`${rect.height + 12}px`}
|
|
141
|
+
></div>
|
|
142
|
+
{:else}
|
|
143
|
+
<div class="sv-tour__backdrop"></div>
|
|
144
|
+
{/if}
|
|
145
|
+
|
|
146
|
+
<div
|
|
147
|
+
bind:this={popEl}
|
|
148
|
+
bind:clientHeight={popH}
|
|
149
|
+
class="sv-tour__pop sv-tour__pop--{pop.place}"
|
|
150
|
+
style:top={pop.top}
|
|
151
|
+
style:left={pop.left}
|
|
152
|
+
style:transform={pop.transform}
|
|
153
|
+
role="dialog"
|
|
154
|
+
aria-modal="true"
|
|
155
|
+
aria-label={step.title ?? 'Tour step'}
|
|
156
|
+
>
|
|
157
|
+
{#if step.image}
|
|
158
|
+
<div class="sv-tour__media">
|
|
159
|
+
{#if /^(https?:|data:|\/)/.test(step.image)}<img class="sv-tour__img" src={step.image} alt="" />{:else}{@html step.image}{/if}
|
|
160
|
+
</div>
|
|
161
|
+
{/if}
|
|
162
|
+
{#if step.title}<div class="sv-tour__title">{step.title}</div>{/if}
|
|
163
|
+
<div class="sv-tour__body">{step.content}</div>
|
|
164
|
+
<div class="sv-tour__foot">
|
|
165
|
+
<span class="sv-tour__count">{index + 1} / {steps.length}</span>
|
|
166
|
+
<div class="sv-tour__btns">
|
|
167
|
+
<button type="button" class="sv-tour__skip" onclick={skip}>Skip</button>
|
|
168
|
+
{#if index > 0}<button type="button" class="sv-tour__back" onclick={back}>Back</button>{/if}
|
|
169
|
+
<button type="button" class="sv-tour__next" onclick={next}>{index === steps.length - 1 ? 'Done' : 'Next'}</button>
|
|
170
|
+
</div>
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
</div>
|
|
174
|
+
{/if}
|
|
175
|
+
|
|
176
|
+
<style>
|
|
177
|
+
:global(.sv-tour) { position: fixed; inset: 0; z-index: 2147483000; }
|
|
178
|
+
:global(.sv-tour__backdrop) { position: fixed; inset: 0; background: rgba(15, 23, 42, 0.55); }
|
|
179
|
+
:global(.sv-tour__spot) {
|
|
180
|
+
position: fixed; border-radius: 8px; pointer-events: none;
|
|
181
|
+
box-shadow: 0 0 0 9999px rgba(15, 23, 42, 0.55); transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
|
|
182
|
+
}
|
|
183
|
+
:global(.sv-tour__pop) {
|
|
184
|
+
position: fixed; z-index: 1; width: 300px; max-width: calc(100vw - 24px);
|
|
185
|
+
background: var(--sg-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
186
|
+
border: 1px solid var(--sg-border, #e2e8f0); border-radius: 12px; padding: 14px 16px;
|
|
187
|
+
box-shadow: 0 20px 48px -16px rgba(15, 23, 42, 0.5);
|
|
188
|
+
animation: sv-tour-in 0.16s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
189
|
+
}
|
|
190
|
+
@keyframes sv-tour-in { from { opacity: 0; transform: translateY(4px) } }
|
|
191
|
+
:global(.sv-tour__pop--top) { animation: none; }
|
|
192
|
+
:global(.sv-tour__media) { margin: -2px -4px 12px; border-radius: 9px; overflow: hidden; line-height: 0; }
|
|
193
|
+
:global(.sv-tour__media svg), :global(.sv-tour__img) { display: block; width: 100%; height: auto; }
|
|
194
|
+
:global(.sv-tour__title) { font-size: 14.5px; font-weight: 650; margin-bottom: 4px; }
|
|
195
|
+
:global(.sv-tour__body) { font-size: 13px; line-height: 1.55; color: var(--sg-fg, #334155); }
|
|
196
|
+
:global(.sv-tour__foot) { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-top: 14px; }
|
|
197
|
+
:global(.sv-tour__count) { font-size: 12px; color: var(--sg-muted, #94a3b8); font-variant-numeric: tabular-nums; }
|
|
198
|
+
:global(.sv-tour__btns) { display: flex; gap: 6px; }
|
|
199
|
+
:global(.sv-tour__skip), :global(.sv-tour__back), :global(.sv-tour__next) {
|
|
200
|
+
font: inherit; font-size: 12.5px; padding: 5px 12px; border-radius: 7px; cursor: pointer; border: 1px solid var(--sg-border, #cbd5e1); background: var(--sg-input-bg, #fff); color: var(--sg-fg, #0f172a);
|
|
201
|
+
}
|
|
202
|
+
:global(.sv-tour__skip) { border-color: transparent; background: none; color: var(--sg-muted, #64748b); }
|
|
203
|
+
:global(.sv-tour__next) { background: var(--sg-accent, #2563eb); border-color: var(--sg-accent, #2563eb); color: var(--sg-on-accent, #fff); }
|
|
204
|
+
</style>
|