@svgrid/grid 3.0.0 → 3.0.2
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/CHANGELOG.md +116 -88
- package/README.md +199 -199
- package/dist/FlexRender.svelte +96 -96
- package/dist/GridFooter.svelte +190 -181
- package/dist/GridFooter.svelte.d.ts +8 -0
- package/dist/GridMenus.svelte +18 -10
- package/dist/GridMenus.svelte.d.ts +2 -1
- package/dist/SvAutoComplete.svelte +169 -169
- package/dist/SvAvatar.svelte +75 -75
- package/dist/SvCalendar.svelte +503 -503
- package/dist/SvCarousel.svelte +141 -141
- package/dist/SvCheckBox.svelte +102 -102
- package/dist/SvCircularProgress.svelte +109 -109
- package/dist/SvColorInput.svelte +181 -181
- package/dist/SvComboBox.svelte +279 -279
- package/dist/SvContextMenu.svelte +116 -116
- package/dist/SvCountryInput.svelte +163 -163
- package/dist/SvDrawer.svelte +254 -254
- package/dist/SvDropDownList.svelte +378 -378
- package/dist/SvDurationInput.svelte +126 -126
- package/dist/SvField.svelte +293 -293
- package/dist/SvForm.svelte +437 -437
- package/dist/SvGrid.controller.svelte.d.ts +17 -0
- package/dist/SvGrid.controller.svelte.js +128 -14
- package/dist/SvGrid.css +79 -10
- package/dist/SvGrid.svelte +191 -186
- package/dist/SvGrid.types.d.ts +42 -1
- package/dist/SvGridCellEditor.svelte +748 -744
- package/dist/SvGridChart.svelte +2246 -1716
- package/dist/SvGridChart.svelte.d.ts +63 -1
- package/dist/SvGridChartPanel.svelte +633 -485
- package/dist/SvGridChartView.svelte +79 -70
- package/dist/SvGridChartView.svelte.d.ts +7 -0
- package/dist/SvGridDropdown.svelte +728 -728
- package/dist/SvGridSelect.svelte +270 -270
- package/dist/SvGroupCell.svelte +141 -116
- package/dist/SvGroupCell.svelte.d.ts +8 -0
- package/dist/SvListBox.svelte +334 -334
- package/dist/SvMaskedInput.svelte +122 -122
- package/dist/SvMenu.svelte +124 -124
- package/dist/SvMenuList.svelte +146 -146
- package/dist/SvMultiSelect.svelte +293 -293
- package/dist/SvNumberInput.svelte +172 -172
- package/dist/SvOtpInput.svelte +158 -158
- package/dist/SvPasswordInput.svelte +151 -151
- package/dist/SvPhoneInput.svelte +133 -133
- package/dist/SvPopover.svelte +197 -197
- package/dist/SvProgress.svelte +116 -116
- package/dist/SvRadioGroup.svelte +107 -107
- package/dist/SvRating.svelte +112 -112
- package/dist/SvResult.svelte +73 -73
- package/dist/SvRichText.svelte +211 -211
- package/dist/SvRowGroupPanel.svelte +186 -170
- package/dist/SvRowGroupPanel.svelte.d.ts +8 -0
- package/dist/SvScrollArea.svelte +61 -61
- package/dist/SvSlider.svelte +203 -203
- package/dist/SvSwitchButton.svelte +108 -108
- package/dist/SvTagsInput.svelte +115 -115
- 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 +208 -208
- package/dist/SvTree.svelte +444 -444
- package/dist/SvTreeSelect.svelte +239 -239
- package/dist/ai.d.ts +11 -2
- package/dist/ai.js +51 -7
- package/dist/cdn/GridMenus-DyFA-QxA.js +602 -0
- package/dist/cdn/GridMenus-M2XP9t0_.js +603 -0
- package/dist/cdn/{SvDateRangeInput-DflbiP7N.js → SvDateRangeInput-CaOuMs8O.js} +1 -1
- package/dist/cdn/{SvDateRangeInput-Bh0A0JkF.js → SvDateRangeInput-DMLKmGEc.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-Bivn8dAP.js → SvDateTimePicker-CCbDZNZB.js} +1 -1
- package/dist/cdn/{SvDateTimePicker-BWwpfB_o.js → SvDateTimePicker-sonaH0oh.js} +1 -1
- package/dist/cdn/{SvGridCellEditor-Ba7rY3eu.js → SvGridCellEditor-BWnTi2N7.js} +2 -2
- package/dist/cdn/{SvGridCellEditor-AXL8cHGO.js → SvGridCellEditor-CjEJMJKc.js} +2 -2
- package/dist/cdn/SvGridChart-DAb9RbRs.js +1480 -0
- package/dist/cdn/SvGridChart-Yl-IXZFO.js +1481 -0
- package/dist/cdn/SvGridChartPanel-DayQpDsR.js +834 -0
- package/dist/cdn/SvGridChartPanel-aqf8n-vJ.js +810 -0
- package/dist/cdn/{SvGridChartView-eSPuJE6g.js → SvGridChartView-CxfakxR0.js} +5 -3
- package/dist/cdn/{SvGridChartView-SmPW10dI.js → SvGridChartView-Dm_YEx9L.js} +5 -3
- package/dist/cdn/{SvGridDropdown-B1ZcLpgs.js → SvGridDropdown-D0VdjeR8.js} +1 -1
- package/dist/cdn/{SvGridDropdown-CtdbHcIS.js → SvGridDropdown-D13MtJ2j.js} +1 -1
- package/dist/cdn/cell-formatting-C2Hf5gqd.js +86 -0
- package/dist/cdn/chart-CKJRCPOk.js +1652 -0
- package/dist/cdn/{date-format-DZT7T1wf.js → date-format-BNii4zeD.js} +2 -2
- package/dist/cdn/{date-format-CcP1tafP.js → date-format-BnnHlqGw.js} +2 -2
- package/dist/cdn/{export-format-Cv1Dll6k.js → export-format-CUDLy2yn.js} +84 -168
- package/dist/cdn/{src-B_YS5AOc.js → src-BJ2opd0j.js} +3642 -3450
- package/dist/cdn/{src-DYXXpuSk.js → src-DNbz94Ct.js} +8649 -8457
- package/dist/cdn/svgrid.js +16 -15
- package/dist/cdn/svgrid.svelte-external.js +16 -15
- package/dist/cell-formatting.d.ts +7 -0
- package/dist/cell-formatting.js +7 -1
- package/dist/chart-export.d.ts +23 -2
- package/dist/chart-export.js +88 -14
- package/dist/chart.d.ts +410 -18
- package/dist/chart.js +824 -24
- package/dist/editing.js +22 -13
- package/dist/grid-icons.d.ts +49 -0
- package/dist/grid-icons.js +86 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.js +3 -2
- package/package.json +1 -1
- package/src/FlexRender.svelte +96 -96
- package/src/GridFooter.svelte +190 -181
- package/src/GridMenus.svelte +18 -10
- package/src/SvAutoComplete.svelte +169 -169
- package/src/SvAvatar.svelte +75 -75
- package/src/SvCalendar.svelte +503 -503
- package/src/SvCalendar.test.ts +226 -226
- package/src/SvCarousel.svelte +141 -141
- package/src/SvCheckBox.svelte +102 -102
- package/src/SvCircularProgress.svelte +109 -109
- package/src/SvColorInput.svelte +181 -181
- package/src/SvComboBox.svelte +279 -279
- package/src/SvContextMenu.svelte +116 -116
- package/src/SvCountryInput.svelte +163 -163
- package/src/SvDrawer.svelte +254 -254
- package/src/SvDropDownList.svelte +378 -378
- 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 +146 -13
- package/src/SvGrid.css +79 -10
- package/src/SvGrid.svelte +191 -186
- package/src/SvGrid.types.ts +2133 -2092
- package/src/SvGridCellEditor.svelte +748 -744
- package/src/SvGridChart.svelte +2246 -1716
- package/src/SvGridChart.test.ts +243 -1
- package/src/SvGridChartPanel.svelte +633 -485
- package/src/SvGridChartView.svelte +79 -70
- package/src/SvGridDropdown.svelte +728 -728
- package/src/SvGridSelect.svelte +270 -270
- package/src/SvGroupCell.svelte +141 -116
- package/src/SvListBox.svelte +334 -334
- 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 +293 -293
- package/src/SvNumberInput.svelte +172 -172
- package/src/SvOtpInput.svelte +158 -158
- package/src/SvPasswordInput.svelte +151 -151
- package/src/SvPhoneInput.svelte +133 -133
- package/src/SvPopover.svelte +197 -197
- package/src/SvProgress.svelte +116 -116
- package/src/SvRadioGroup.svelte +107 -107
- package/src/SvRating.svelte +112 -112
- package/src/SvResult.svelte +73 -73
- package/src/SvRichText.svelte +211 -211
- package/src/SvRowGroupPanel.svelte +186 -170
- package/src/SvScrollArea.svelte +61 -61
- package/src/SvSlider.svelte +203 -203
- package/src/SvSwitchButton.svelte +108 -108
- package/src/SvTagsInput.svelte +115 -115
- 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 +208 -208
- package/src/SvTree.svelte +444 -444
- package/src/SvTreeSelect.svelte +239 -239
- package/src/a11y/dismissable.test.ts +119 -119
- package/src/a11y/dismissable.ts +114 -114
- 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 +561 -502
- package/src/ai.ts +1472 -1419
- package/src/build-api.coverage.test.ts +633 -633
- package/src/build-api.ts +846 -846
- package/src/builtin-editors.grid.test.ts +83 -83
- package/src/cell-formatting.ts +177 -171
- package/src/cell-render.test.ts +513 -513
- package/src/cell-render.ts +496 -496
- package/src/cell-values.ts +148 -148
- package/src/chart-export.test.ts +112 -0
- package/src/chart-export.ts +270 -202
- package/src/chart-view.svelte.ts +36 -36
- package/src/chart.test.ts +830 -1
- package/src/chart.ts +3342 -2321
- package/src/collaboration.test.ts +104 -104
- package/src/collaboration.ts +167 -167
- package/src/column-groups.ts +78 -78
- package/src/core.performance.test.ts +30 -30
- package/src/core.ts +1865 -1865
- 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/createListbox.svelte.ts +250 -250
- package/src/createMenu.svelte.ts +224 -224
- 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 +322 -322
- package/src/datetime/date-core.ts +206 -206
- package/src/datetime/date-restrict.ts +61 -61
- package/src/datetime/timezone.ts +135 -135
- package/src/dock-manager-model.ts +596 -596
- package/src/dock-model.ts +374 -374
- package/src/editing.test.ts +974 -974
- package/src/editing.ts +618 -609
- package/src/editor-contract.ts +171 -171
- package/src/editor-registry.grid.test.ts +144 -144
- package/src/editor-registry.ts +122 -122
- package/src/export-data-api.test.ts +126 -126
- package/src/export-format.test.ts +107 -107
- package/src/export-format.ts +601 -601
- package/src/filter-operators.ts +160 -160
- package/src/filtering/excel-filters.ts +325 -325
- package/src/flex-render.ts +3 -3
- package/src/form-field.ts +127 -127
- package/src/grid-icons.test.ts +46 -0
- package/src/grid-icons.ts +184 -0
- package/src/group-display.test.ts +167 -167
- package/src/group-display.ts +200 -200
- package/src/headless.ts +87 -87
- package/src/icon-overrides.grid.test.ts +188 -0
- package/src/icon-seam.test.ts +100 -0
- package/src/index.ts +19 -0
- package/src/js-scroller.svelte.ts +173 -173
- package/src/keyboard-handlers.ts +270 -270
- 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 +179 -179
- package/src/menus.ts +597 -597
- package/src/merge-objects.ts +48 -48
- package/src/overlays.test.ts +90 -90
- package/src/positioning.ts +268 -268
- package/src/render-component.ts +28 -28
- package/src/row-drag.test.ts +401 -401
- package/src/row-drag.ts +419 -419
- package/src/row-resize.test.ts +524 -524
- package/src/row-resize.ts +228 -228
- package/src/scheduler-ical.ts +181 -181
- package/src/scheduler-model.test.ts +562 -562
- package/src/scheduler-model.ts +873 -873
- package/src/selection.test.ts +885 -885
- package/src/server-data-source.test.ts +383 -383
- package/src/server-data-source.ts +469 -469
- package/src/sparkline.test.ts +68 -68
- package/src/sparkline.ts +169 -169
- package/src/spreadsheet.test.ts +488 -488
- package/src/spreadsheet.ts +312 -312
- package/src/static-functions.ts +11 -11
- package/src/subscribe.ts +38 -38
- package/src/summaries.ts +113 -113
- package/src/svgrid-wrapper.types.ts +563 -563
- package/src/svgrid.async-editor-options.test.ts +273 -273
- package/src/svgrid.auto-row-height.test.ts +204 -204
- package/src/svgrid.behavior.test.ts +910 -910
- package/src/svgrid.charting.test.ts +654 -534
- package/src/svgrid.comments-autocomplete.test.ts +127 -127
- package/src/svgrid.context-menu.test.ts +147 -147
- package/src/svgrid.features.test.ts +157 -157
- package/src/svgrid.filter-depth.test.ts +163 -163
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +382 -377
- package/src/svgrid.filter-menu-scroll.test.ts +112 -112
- package/src/svgrid.grand-total.test.ts +188 -188
- package/src/svgrid.group-display-mode.test.ts +171 -171
- package/src/svgrid.group-footers.test.ts +121 -121
- package/src/svgrid.group-pagination.test.ts +153 -153
- package/src/svgrid.new-features.wrapper.test.ts +251 -251
- package/src/svgrid.tree-data.test.ts +186 -186
- package/src/svgrid.wrapper.test.ts +63 -63
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -195
- package/src/test-setup.ts +62 -62
- package/src/themes/index.ts +288 -288
- package/src/toast-store.svelte.ts +250 -250
- package/src/toast-store.test.ts +147 -147
- package/src/tree-row-model.test.ts +168 -168
- package/src/ui-buttons.test.ts +144 -144
- package/src/ui-inputs.test.ts +118 -118
- package/src/ui-localization.test.ts +113 -113
- 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 +322 -322
- package/dist/cdn/GridMenus-DMihUtma.js +0 -600
- package/dist/cdn/GridMenus-nmNDj1a3.js +0 -601
- package/dist/cdn/SvGridChart-Bs2GIR2Q.js +0 -1202
- package/dist/cdn/SvGridChart-CwhFz7GV.js +0 -1203
- package/dist/cdn/SvGridChartPanel-1WXuStzM.js +0 -561
- package/dist/cdn/SvGridChartPanel-e5_nqNP9.js +0 -585
- package/dist/cdn/chart-i4XcXyHJ.js +0 -1214
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: the `icons` prop replaces the grid's own glyphs, one name at a time.
|
|
3
|
+
*
|
|
4
|
+
* The contract these lock in:
|
|
5
|
+
* - per-name fallback. Overriding one icon must not blank the others; that
|
|
6
|
+
* cliff is the whole reason `icons` is a map and not a single snippet.
|
|
7
|
+
* - an override reaches the lazily-imported column menu, not just the parts
|
|
8
|
+
* of the grid that render eagerly.
|
|
9
|
+
* - overriding a filter operator does NOT repaint the pin menu, which shared
|
|
10
|
+
* its glyphs before this feature.
|
|
11
|
+
* - defaults are untouched when `icons` is absent.
|
|
12
|
+
*
|
|
13
|
+
* jsdom does not compute `transform`, so the rotation contract is asserted as
|
|
14
|
+
* the class hook the CSS keys off (`.sv-grid-icon` on the wrapper, as a direct
|
|
15
|
+
* child of the toggle) rather than as a computed style. The rotation itself is
|
|
16
|
+
* browser-only verification.
|
|
17
|
+
*/
|
|
18
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
19
|
+
import { createRawSnippet, flushSync, mount, unmount } from 'svelte'
|
|
20
|
+
import SvGrid from './SvGrid.svelte'
|
|
21
|
+
import {
|
|
22
|
+
createCoreRowModel,
|
|
23
|
+
createFilteredRowModel,
|
|
24
|
+
createPaginatedRowModel,
|
|
25
|
+
createSortedRowModel,
|
|
26
|
+
sortFns,
|
|
27
|
+
tableFeatures,
|
|
28
|
+
rowSortingFeature,
|
|
29
|
+
} from './index'
|
|
30
|
+
import type { ColumnDef } from './index'
|
|
31
|
+
|
|
32
|
+
type Row = { id: number; name: string }
|
|
33
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
34
|
+
const cols: ColumnDef<typeof features, Row>[] = [{ field: 'name', header: 'Name', width: 160 }]
|
|
35
|
+
const data: Row[] = Array.from({ length: 12 }, (_, i) => ({ id: i, name: `n${i}` }))
|
|
36
|
+
|
|
37
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
38
|
+
|
|
39
|
+
/** A marker icon that is trivially findable in the DOM. */
|
|
40
|
+
const mark = (id: string) =>
|
|
41
|
+
createRawSnippet(() => ({
|
|
42
|
+
render: () => `<svg data-icon="${id}"><path d="M0 0" /></svg>`,
|
|
43
|
+
}))
|
|
44
|
+
|
|
45
|
+
function mountGrid(props: Record<string, unknown>) {
|
|
46
|
+
const target = document.createElement('div')
|
|
47
|
+
document.body.appendChild(target)
|
|
48
|
+
const app = mount(SvGrid, {
|
|
49
|
+
target,
|
|
50
|
+
props: {
|
|
51
|
+
data,
|
|
52
|
+
columns: cols,
|
|
53
|
+
features,
|
|
54
|
+
_rowModels: {
|
|
55
|
+
coreRowModel: createCoreRowModel(),
|
|
56
|
+
filteredRowModel: createFilteredRowModel(),
|
|
57
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
58
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
59
|
+
},
|
|
60
|
+
containerHeight: 240,
|
|
61
|
+
virtualization: false,
|
|
62
|
+
...props,
|
|
63
|
+
} as never,
|
|
64
|
+
})
|
|
65
|
+
return { target, app, destroy: () => { unmount(app); target.remove() } }
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
let cleanup: (() => void) | null = null
|
|
69
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
70
|
+
|
|
71
|
+
describe('icons prop', () => {
|
|
72
|
+
it('renders the built-in glyphs when no icons are passed', async () => {
|
|
73
|
+
const { target, destroy } = mountGrid({ showColumnMenu: true })
|
|
74
|
+
cleanup = destroy
|
|
75
|
+
await tick()
|
|
76
|
+
// The header menu button draws a real <svg> from the built-in catalogue.
|
|
77
|
+
const menuBtn = target.querySelector('.sv-grid-col-menu-btn')
|
|
78
|
+
expect(menuBtn).not.toBeNull()
|
|
79
|
+
expect(menuBtn!.querySelector('svg.sv-grid-icon')).not.toBeNull()
|
|
80
|
+
expect(target.querySelector('[data-icon]')).toBeNull()
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('replaces one icon and leaves the rest alone', async () => {
|
|
84
|
+
const { target, destroy } = mountGrid({
|
|
85
|
+
showColumnMenu: true,
|
|
86
|
+
showFilterRow: true,
|
|
87
|
+
icons: { menu: mark('m') },
|
|
88
|
+
})
|
|
89
|
+
cleanup = destroy
|
|
90
|
+
await tick()
|
|
91
|
+
|
|
92
|
+
const menuBtn = target.querySelector('.sv-grid-col-menu-btn')!
|
|
93
|
+
// The override is there...
|
|
94
|
+
expect(menuBtn.querySelector('[data-icon="m"]')).not.toBeNull()
|
|
95
|
+
// ...and the built-in it replaced is not drawn alongside it.
|
|
96
|
+
expect(menuBtn.querySelector('svg.sv-grid-icon')).toBeNull()
|
|
97
|
+
// An unset name still renders its built-in. This is the assertion that a
|
|
98
|
+
// single catch-all snippet could not satisfy.
|
|
99
|
+
expect(target.querySelectorAll('svg.sv-grid-icon').length).toBeGreaterThan(0)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('wraps an override in the icon box the CSS keys off', async () => {
|
|
103
|
+
const { target, destroy } = mountGrid({ showColumnMenu: true, icons: { menu: mark('m') } })
|
|
104
|
+
cleanup = destroy
|
|
105
|
+
await tick()
|
|
106
|
+
const wrapper = target.querySelector('[data-icon="m"]')!.parentElement!
|
|
107
|
+
expect(wrapper.classList.contains('sv-grid-icon')).toBe(true)
|
|
108
|
+
expect(wrapper.classList.contains('sv-grid-icon-custom')).toBe(true)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
it('reaches the lazily-imported column menu', async () => {
|
|
112
|
+
const { target, destroy } = mountGrid({
|
|
113
|
+
showColumnMenu: true,
|
|
114
|
+
icons: { 'sort-asc': mark('sa') },
|
|
115
|
+
})
|
|
116
|
+
cleanup = destroy
|
|
117
|
+
await tick()
|
|
118
|
+
;(target.querySelector('.sv-grid-col-menu-btn') as HTMLElement).click()
|
|
119
|
+
// GridMenus arrives through a dynamic import(), so poll rather than
|
|
120
|
+
// guessing a tick count.
|
|
121
|
+
await vi.waitFor(() => {
|
|
122
|
+
expect(target.querySelector('[data-icon="sa"]')).not.toBeNull()
|
|
123
|
+
})
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('does not repaint the pin menu when a filter operator is overridden', async () => {
|
|
127
|
+
const { target, destroy } = mountGrid({
|
|
128
|
+
showColumnMenu: true,
|
|
129
|
+
// The pin section is hidden while columns are virtualized, and that
|
|
130
|
+
// defaults to on. This is COLUMN virtualization, not the row
|
|
131
|
+
// `virtualization: false` the shared helper sets.
|
|
132
|
+
columnVirtualization: false,
|
|
133
|
+
icons: { 'op-startsWith': mark('sw') },
|
|
134
|
+
})
|
|
135
|
+
cleanup = destroy
|
|
136
|
+
await tick()
|
|
137
|
+
;(target.querySelector('.sv-grid-col-menu-btn') as HTMLElement).click()
|
|
138
|
+
|
|
139
|
+
// Assert the item is really there before asserting about its icon -
|
|
140
|
+
// a conditional check here would pass just as happily if the pin menu
|
|
141
|
+
// stopped rendering at all.
|
|
142
|
+
const pinLeft = await vi.waitFor(() => {
|
|
143
|
+
const el = [...target.querySelectorAll('.sv-grid-menu-item')].find((e) =>
|
|
144
|
+
e.textContent?.includes('Pin to left'),
|
|
145
|
+
)
|
|
146
|
+
expect(el).toBeTruthy()
|
|
147
|
+
return el!
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
// Pin borrowed `op-startsWith` before this feature; it must not follow
|
|
151
|
+
// that override now.
|
|
152
|
+
expect(pinLeft.querySelector('[data-icon="sw"]')).toBeNull()
|
|
153
|
+
expect(pinLeft.querySelector('svg.sv-grid-icon')).not.toBeNull()
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
it('overrides the pager arrows through GridFooter', async () => {
|
|
157
|
+
const { target, destroy } = mountGrid({
|
|
158
|
+
showPagination: true,
|
|
159
|
+
pageSize: 5,
|
|
160
|
+
icons: { 'page-next': mark('pn') },
|
|
161
|
+
})
|
|
162
|
+
cleanup = destroy
|
|
163
|
+
await tick()
|
|
164
|
+
const hit = target.querySelector('[data-icon="pn"]')
|
|
165
|
+
expect(hit).not.toBeNull()
|
|
166
|
+
// Still a real pager button, and its label is untouched: icons and
|
|
167
|
+
// localeText are independent axes.
|
|
168
|
+
const btn = hit!.closest('.sv-grid-pagination-btn')
|
|
169
|
+
expect(btn).not.toBeNull()
|
|
170
|
+
expect(btn!.getAttribute('aria-label')).toBe('Next page')
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
it('picks up icons set after mount through api.setOption', async () => {
|
|
174
|
+
let api: { setOption: (k: string, v: unknown) => void } | undefined
|
|
175
|
+
const { target, destroy } = mountGrid({
|
|
176
|
+
showColumnMenu: true,
|
|
177
|
+
onApiReady: (a: typeof api) => (api = a),
|
|
178
|
+
})
|
|
179
|
+
cleanup = destroy
|
|
180
|
+
await tick()
|
|
181
|
+
expect(target.querySelector('[data-icon="rt"]')).toBeNull()
|
|
182
|
+
|
|
183
|
+
api!.setOption('icons', { menu: mark('rt') })
|
|
184
|
+
flushSync()
|
|
185
|
+
await tick()
|
|
186
|
+
expect(target.querySelector('[data-icon="rt"]')).not.toBeNull()
|
|
187
|
+
})
|
|
188
|
+
})
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Source scan: every chrome icon goes through the `icon` snippet.
|
|
3
|
+
*
|
|
4
|
+
* The `icons` prop is only worth having if it covers the chrome, and coverage
|
|
5
|
+
* is the kind of thing that rots one commit at a time - somebody adds a toolbar
|
|
6
|
+
* button with an inline `<svg>`, it works, nobody notices it ignores `icons`
|
|
7
|
+
* until a consumer reports a stray glyph in a re-skinned grid. This is the
|
|
8
|
+
* check that turns that into a red test instead.
|
|
9
|
+
*
|
|
10
|
+
* It reads source rather than DOM on purpose: a rendering test can only cover
|
|
11
|
+
* the states it happens to mount, and the icons most likely to be missed are
|
|
12
|
+
* the ones behind a rarely-exercised branch.
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs'
|
|
15
|
+
import { join } from 'node:path'
|
|
16
|
+
import { describe, expect, it } from 'vitest'
|
|
17
|
+
import { GRID_ICON_GLYPHS, GRID_ICON_NAMES, type GridIconName } from './grid-icons'
|
|
18
|
+
|
|
19
|
+
const SRC = join(process.cwd(), 'src')
|
|
20
|
+
const read = (f: string) => readFileSync(join(SRC, f), 'utf8')
|
|
21
|
+
|
|
22
|
+
/** Files that render grid chrome and therefore must go through the seam. */
|
|
23
|
+
const CHROME = ['SvGrid.svelte', 'GridMenus.svelte', 'GridFooter.svelte', 'SvRowGroupPanel.svelte']
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Drop the regions allowed to contain raw icon markup: the `icon` snippet's own
|
|
27
|
+
* body (which IS the built-in set), the `ic` snippet in SvRowGroupPanel, and
|
|
28
|
+
* every `<script>` block.
|
|
29
|
+
*/
|
|
30
|
+
function stripAllowedRegions(src: string): string {
|
|
31
|
+
let out = src.replace(/<script[\s\S]*?<\/script>/g, '')
|
|
32
|
+
// The built-in catalogue: from the snippet header to its closing tag.
|
|
33
|
+
out = out.replace(/\{#snippet icon\(name: GridIconName\)\}[\s\S]*?\n\{\/snippet\}/g, '')
|
|
34
|
+
out = out.replace(/\{#snippet ic\(name: GridIconName\)\}[\s\S]*?\{\/snippet\}/g, '')
|
|
35
|
+
return out
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
describe('icon seam', () => {
|
|
39
|
+
it('leaves no inline <svg> outside the built-in catalogue', () => {
|
|
40
|
+
// Data visualisation is not chrome: these draw values, not affordances, and
|
|
41
|
+
// there is nothing for a consumer's icon set to say about them.
|
|
42
|
+
const ALLOWED = ['sv-grid-sparkline']
|
|
43
|
+
|
|
44
|
+
const offenders: string[] = []
|
|
45
|
+
for (const file of CHROME) {
|
|
46
|
+
const body = stripAllowedRegions(read(file))
|
|
47
|
+
for (const [i, line] of body.split('\n').entries()) {
|
|
48
|
+
if (!line.includes('<svg')) continue
|
|
49
|
+
if (ALLOWED.some((a) => body.split('\n').slice(i, i + 3).join(' ').includes(a))) continue
|
|
50
|
+
offenders.push(`${file}:${i + 1} ${line.trim().slice(0, 80)}`)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
expect(
|
|
54
|
+
offenders,
|
|
55
|
+
`Inline <svg> outside the icon snippet - route it through {@render icon("...")} ` +
|
|
56
|
+
`and add a name to grid-icons.ts:\n ${offenders.join('\n ')}`,
|
|
57
|
+
).toEqual([])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('leaves no glyph character used as an icon', () => {
|
|
61
|
+
// Every character the catalogue owns. If one of these appears as an
|
|
62
|
+
// element's text content outside the snippet, it is an icon that bypassed
|
|
63
|
+
// the seam.
|
|
64
|
+
const glyphs = [...new Set(Object.values(GRID_ICON_GLYPHS))].filter((g) => g !== '#')
|
|
65
|
+
const offenders: string[] = []
|
|
66
|
+
for (const file of CHROME) {
|
|
67
|
+
const body = stripAllowedRegions(read(file))
|
|
68
|
+
for (const [i, line] of body.split('\n').entries()) {
|
|
69
|
+
for (const g of glyphs) {
|
|
70
|
+
// `>GLYPH<` is the shape of a glyph rendered as element content.
|
|
71
|
+
if (new RegExp(`>\\s*${escapeRe(g)}\\s*<`).test(line)) {
|
|
72
|
+
offenders.push(`${file}:${i + 1} ${JSON.stringify(g)} in ${line.trim().slice(0, 70)}`)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
expect(
|
|
78
|
+
offenders,
|
|
79
|
+
`Glyph rendered directly instead of through the icon seam:\n ${offenders.join('\n ')}`,
|
|
80
|
+
).toEqual([])
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('only renders names the catalogue defines', () => {
|
|
84
|
+
const unknown: string[] = []
|
|
85
|
+
for (const file of CHROME) {
|
|
86
|
+
const src = read(file)
|
|
87
|
+
for (const m of src.matchAll(/\{@render ic(?:on)?\(\s*["']([a-zA-Z-]+)["']\s*\)\}/g)) {
|
|
88
|
+
const name = m[1] as GridIconName
|
|
89
|
+
if (!GRID_ICON_NAMES.includes(name)) unknown.push(`${file}: ${name}`)
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
expect(unknown, `Icon names with no entry in grid-icons.ts:\n ${unknown.join('\n ')}`).toEqual(
|
|
93
|
+
[],
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
function escapeRe(s: string): string {
|
|
99
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
100
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -536,6 +536,12 @@ export {
|
|
|
536
536
|
buildChart,
|
|
537
537
|
formatChartValue,
|
|
538
538
|
rowsToChartSpec,
|
|
539
|
+
rowsToBoxSpec,
|
|
540
|
+
rowsToDirectSpec,
|
|
541
|
+
boxStats,
|
|
542
|
+
sliceChartWindow,
|
|
543
|
+
chartScales,
|
|
544
|
+
ordinalDateTicks,
|
|
539
545
|
niceScale,
|
|
540
546
|
niceLogScale,
|
|
541
547
|
linearTrend,
|
|
@@ -550,7 +556,13 @@ export {
|
|
|
550
556
|
type ChartSpec,
|
|
551
557
|
type ChartSeries,
|
|
552
558
|
type ChartGeometry,
|
|
559
|
+
type ChartScales,
|
|
553
560
|
type ChartBar,
|
|
561
|
+
type ChartCandle,
|
|
562
|
+
type ChartBox,
|
|
563
|
+
type ChartErrorBar,
|
|
564
|
+
type BoxStats,
|
|
565
|
+
type OhlcBar,
|
|
554
566
|
type ChartLine,
|
|
555
567
|
type ChartLinePoint,
|
|
556
568
|
type ChartPieSlice,
|
|
@@ -581,6 +593,7 @@ export {
|
|
|
581
593
|
downloadChartSvg,
|
|
582
594
|
chartToPngBlob,
|
|
583
595
|
downloadChartPng,
|
|
596
|
+
chartCsvExportable,
|
|
584
597
|
chartSpecToCsv,
|
|
585
598
|
downloadChartCsv,
|
|
586
599
|
type ChartExportOptions,
|
|
@@ -693,6 +706,12 @@ export {
|
|
|
693
706
|
resolveGridMessages,
|
|
694
707
|
type GridMessages,
|
|
695
708
|
} from './grid-messages'
|
|
709
|
+
export {
|
|
710
|
+
GRID_ICON_GLYPHS,
|
|
711
|
+
GRID_ICON_NAMES,
|
|
712
|
+
type GridIconName,
|
|
713
|
+
type GridIcons,
|
|
714
|
+
} from './grid-icons'
|
|
696
715
|
export {
|
|
697
716
|
insertGroupFooters,
|
|
698
717
|
buildAutoGroupColumns,
|
|
@@ -1,173 +1,173 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* createJsScroller - a JS-driven vertical scroller for the virtualized selection
|
|
3
|
-
* controls (SvListBox / SvDropDownList / SvTree).
|
|
4
|
-
*
|
|
5
|
-
* Native overflow scroll is composited off the main thread, so on a fast
|
|
6
|
-
* scrollbar-thumb drag the browser paints the new (empty) scroll position a
|
|
7
|
-
* frame before JS can re-window the virtualizer - the "blank flash while
|
|
8
|
-
* scrolling". This helper removes that async gap the way Smart's list does: the
|
|
9
|
-
* content does NOT use native scroll. A single `scrollOffset` state positions
|
|
10
|
-
* the rows (`translateY`) AND drives the virtualizer window in the same reactive
|
|
11
|
-
* flush, and a custom scrollbar + wheel/touch handlers set that offset directly.
|
|
12
|
-
*
|
|
13
|
-
* The component owns the markup (translated rows + the `sb`/`thumb` elements);
|
|
14
|
-
* this owns the offset, the geometry, and the input handlers.
|
|
15
|
-
*/
|
|
16
|
-
import { untrack } from 'svelte'
|
|
17
|
-
|
|
18
|
-
type ScrollerVirtualizer = {
|
|
19
|
-
setScrollOffset: (offset: number) => void
|
|
20
|
-
getOffsetForIndex: (index: number) => number
|
|
21
|
-
getSizeForIndex: (index: number) => number
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export type JsScrollerConfig = {
|
|
25
|
-
/** The shared virtualizer to drive. */
|
|
26
|
-
virtualizer: ScrollerVirtualizer
|
|
27
|
-
/** Full scrollable content height (px). */
|
|
28
|
-
totalSize: () => number
|
|
29
|
-
/** Visible viewport height (px). */
|
|
30
|
-
viewport: () => number
|
|
31
|
-
/** Row height used to convert line-mode wheel deltas to px. */
|
|
32
|
-
lineStep: () => number
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createJsScroller(cfg: JsScrollerConfig) {
|
|
36
|
-
// The single source of truth for the scroll position.
|
|
37
|
-
let scrollOffset = $state(0)
|
|
38
|
-
const maxOffset = $derived(Math.max(0, cfg.totalSize() - cfg.viewport()))
|
|
39
|
-
|
|
40
|
-
// Push the offset into the virtualizer whenever it changes. `untrack` keeps
|
|
41
|
-
// the virtualizer's `version` write out of this effect's dependencies.
|
|
42
|
-
$effect(() => {
|
|
43
|
-
const o = scrollOffset
|
|
44
|
-
untrack(() => cfg.virtualizer.setScrollOffset(o))
|
|
45
|
-
})
|
|
46
|
-
// Re-clamp if the content shrinks under the current offset.
|
|
47
|
-
$effect(() => {
|
|
48
|
-
const m = maxOffset
|
|
49
|
-
if (untrack(() => scrollOffset) > m) scrollOffset = m
|
|
50
|
-
})
|
|
51
|
-
|
|
52
|
-
function scrollTo(offset: number) {
|
|
53
|
-
scrollOffset = Math.max(0, Math.min(maxOffset, offset))
|
|
54
|
-
}
|
|
55
|
-
function scrollByPx(delta: number) {
|
|
56
|
-
scrollTo(scrollOffset + delta)
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function onWheel(e: WheelEvent) {
|
|
60
|
-
if (maxOffset <= 0) return
|
|
61
|
-
// deltaMode: 0 = pixels, 1 = lines, 2 = pages.
|
|
62
|
-
const step =
|
|
63
|
-
e.deltaMode === 1 ? e.deltaY * cfg.lineStep() : e.deltaMode === 2 ? e.deltaY * cfg.viewport() : e.deltaY
|
|
64
|
-
const before = scrollOffset
|
|
65
|
-
scrollByPx(step)
|
|
66
|
-
// Only swallow the wheel when it moved us, so the page can scroll once the
|
|
67
|
-
// list is pinned at its top/bottom edge.
|
|
68
|
-
if (scrollOffset !== before) e.preventDefault()
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// --- Custom scrollbar geometry --------------------------------------------
|
|
72
|
-
const thumbH = $derived(
|
|
73
|
-
maxOffset > 0 && cfg.totalSize() > 0
|
|
74
|
-
? Math.max(24, Math.min(cfg.viewport(), (cfg.viewport() / cfg.totalSize()) * cfg.viewport()))
|
|
75
|
-
: 0,
|
|
76
|
-
)
|
|
77
|
-
const trackRange = $derived(Math.max(0, cfg.viewport() - thumbH))
|
|
78
|
-
const thumbTop = $derived(maxOffset > 0 ? (scrollOffset / maxOffset) * trackRange : 0)
|
|
79
|
-
|
|
80
|
-
let dragging = $state(false)
|
|
81
|
-
let dragStartY = 0
|
|
82
|
-
let dragStartOffset = 0
|
|
83
|
-
function onThumbDown(e: PointerEvent) {
|
|
84
|
-
e.stopPropagation()
|
|
85
|
-
e.preventDefault()
|
|
86
|
-
dragging = true
|
|
87
|
-
dragStartY = e.clientY
|
|
88
|
-
dragStartOffset = scrollOffset
|
|
89
|
-
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
90
|
-
}
|
|
91
|
-
function onThumbMove(e: PointerEvent) {
|
|
92
|
-
if (!dragging) return
|
|
93
|
-
const dy = e.clientY - dragStartY
|
|
94
|
-
scrollTo(dragStartOffset + (trackRange > 0 ? (dy / trackRange) * maxOffset : 0))
|
|
95
|
-
}
|
|
96
|
-
function onThumbUp(e: PointerEvent) {
|
|
97
|
-
dragging = false
|
|
98
|
-
releaseCapture(e)
|
|
99
|
-
}
|
|
100
|
-
function onTrackDown(e: PointerEvent) {
|
|
101
|
-
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
102
|
-
scrollByPx(e.clientY - rect.top < thumbTop ? -cfg.viewport() : cfg.viewport())
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
// --- Touch: drag the content to pan ---------------------------------------
|
|
106
|
-
let touchActive = false
|
|
107
|
-
let touchStartY = 0
|
|
108
|
-
let touchStartOffset = 0
|
|
109
|
-
function onContentDown(e: PointerEvent) {
|
|
110
|
-
if (e.pointerType !== 'touch') return
|
|
111
|
-
touchActive = true
|
|
112
|
-
touchStartY = e.clientY
|
|
113
|
-
touchStartOffset = scrollOffset
|
|
114
|
-
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
115
|
-
}
|
|
116
|
-
function onContentMove(e: PointerEvent) {
|
|
117
|
-
if (!touchActive) return
|
|
118
|
-
scrollTo(touchStartOffset - (e.clientY - touchStartY))
|
|
119
|
-
}
|
|
120
|
-
function onContentUp(e: PointerEvent) {
|
|
121
|
-
if (!touchActive) return
|
|
122
|
-
touchActive = false
|
|
123
|
-
releaseCapture(e)
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function releaseCapture(e: PointerEvent) {
|
|
127
|
-
try {
|
|
128
|
-
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
129
|
-
} catch {
|
|
130
|
-
/* capture may already be gone */
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** Scroll the minimal amount to bring `[top, top+h]` fully into view. */
|
|
135
|
-
function ensureVisible(top: number, h: number) {
|
|
136
|
-
if (top < scrollOffset) scrollTo(top)
|
|
137
|
-
else if (top + h > scrollOffset + cfg.viewport()) scrollTo(top + h - cfg.viewport())
|
|
138
|
-
}
|
|
139
|
-
/** Bring a virtualizer row index into view (variable-height aware). */
|
|
140
|
-
function ensureIndexVisible(index: number) {
|
|
141
|
-
ensureVisible(cfg.virtualizer.getOffsetForIndex(index), cfg.virtualizer.getSizeForIndex(index))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return {
|
|
145
|
-
get scrollOffset() {
|
|
146
|
-
return scrollOffset
|
|
147
|
-
},
|
|
148
|
-
get maxOffset() {
|
|
149
|
-
return maxOffset
|
|
150
|
-
},
|
|
151
|
-
get thumbH() {
|
|
152
|
-
return thumbH
|
|
153
|
-
},
|
|
154
|
-
get thumbTop() {
|
|
155
|
-
return thumbTop
|
|
156
|
-
},
|
|
157
|
-
get dragging() {
|
|
158
|
-
return dragging
|
|
159
|
-
},
|
|
160
|
-
scrollTo,
|
|
161
|
-
scrollByPx,
|
|
162
|
-
ensureVisible,
|
|
163
|
-
ensureIndexVisible,
|
|
164
|
-
onWheel,
|
|
165
|
-
onThumbDown,
|
|
166
|
-
onThumbMove,
|
|
167
|
-
onThumbUp,
|
|
168
|
-
onTrackDown,
|
|
169
|
-
onContentDown,
|
|
170
|
-
onContentMove,
|
|
171
|
-
onContentUp,
|
|
172
|
-
}
|
|
173
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* createJsScroller - a JS-driven vertical scroller for the virtualized selection
|
|
3
|
+
* controls (SvListBox / SvDropDownList / SvTree).
|
|
4
|
+
*
|
|
5
|
+
* Native overflow scroll is composited off the main thread, so on a fast
|
|
6
|
+
* scrollbar-thumb drag the browser paints the new (empty) scroll position a
|
|
7
|
+
* frame before JS can re-window the virtualizer - the "blank flash while
|
|
8
|
+
* scrolling". This helper removes that async gap the way Smart's list does: the
|
|
9
|
+
* content does NOT use native scroll. A single `scrollOffset` state positions
|
|
10
|
+
* the rows (`translateY`) AND drives the virtualizer window in the same reactive
|
|
11
|
+
* flush, and a custom scrollbar + wheel/touch handlers set that offset directly.
|
|
12
|
+
*
|
|
13
|
+
* The component owns the markup (translated rows + the `sb`/`thumb` elements);
|
|
14
|
+
* this owns the offset, the geometry, and the input handlers.
|
|
15
|
+
*/
|
|
16
|
+
import { untrack } from 'svelte'
|
|
17
|
+
|
|
18
|
+
type ScrollerVirtualizer = {
|
|
19
|
+
setScrollOffset: (offset: number) => void
|
|
20
|
+
getOffsetForIndex: (index: number) => number
|
|
21
|
+
getSizeForIndex: (index: number) => number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type JsScrollerConfig = {
|
|
25
|
+
/** The shared virtualizer to drive. */
|
|
26
|
+
virtualizer: ScrollerVirtualizer
|
|
27
|
+
/** Full scrollable content height (px). */
|
|
28
|
+
totalSize: () => number
|
|
29
|
+
/** Visible viewport height (px). */
|
|
30
|
+
viewport: () => number
|
|
31
|
+
/** Row height used to convert line-mode wheel deltas to px. */
|
|
32
|
+
lineStep: () => number
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createJsScroller(cfg: JsScrollerConfig) {
|
|
36
|
+
// The single source of truth for the scroll position.
|
|
37
|
+
let scrollOffset = $state(0)
|
|
38
|
+
const maxOffset = $derived(Math.max(0, cfg.totalSize() - cfg.viewport()))
|
|
39
|
+
|
|
40
|
+
// Push the offset into the virtualizer whenever it changes. `untrack` keeps
|
|
41
|
+
// the virtualizer's `version` write out of this effect's dependencies.
|
|
42
|
+
$effect(() => {
|
|
43
|
+
const o = scrollOffset
|
|
44
|
+
untrack(() => cfg.virtualizer.setScrollOffset(o))
|
|
45
|
+
})
|
|
46
|
+
// Re-clamp if the content shrinks under the current offset.
|
|
47
|
+
$effect(() => {
|
|
48
|
+
const m = maxOffset
|
|
49
|
+
if (untrack(() => scrollOffset) > m) scrollOffset = m
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
function scrollTo(offset: number) {
|
|
53
|
+
scrollOffset = Math.max(0, Math.min(maxOffset, offset))
|
|
54
|
+
}
|
|
55
|
+
function scrollByPx(delta: number) {
|
|
56
|
+
scrollTo(scrollOffset + delta)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onWheel(e: WheelEvent) {
|
|
60
|
+
if (maxOffset <= 0) return
|
|
61
|
+
// deltaMode: 0 = pixels, 1 = lines, 2 = pages.
|
|
62
|
+
const step =
|
|
63
|
+
e.deltaMode === 1 ? e.deltaY * cfg.lineStep() : e.deltaMode === 2 ? e.deltaY * cfg.viewport() : e.deltaY
|
|
64
|
+
const before = scrollOffset
|
|
65
|
+
scrollByPx(step)
|
|
66
|
+
// Only swallow the wheel when it moved us, so the page can scroll once the
|
|
67
|
+
// list is pinned at its top/bottom edge.
|
|
68
|
+
if (scrollOffset !== before) e.preventDefault()
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// --- Custom scrollbar geometry --------------------------------------------
|
|
72
|
+
const thumbH = $derived(
|
|
73
|
+
maxOffset > 0 && cfg.totalSize() > 0
|
|
74
|
+
? Math.max(24, Math.min(cfg.viewport(), (cfg.viewport() / cfg.totalSize()) * cfg.viewport()))
|
|
75
|
+
: 0,
|
|
76
|
+
)
|
|
77
|
+
const trackRange = $derived(Math.max(0, cfg.viewport() - thumbH))
|
|
78
|
+
const thumbTop = $derived(maxOffset > 0 ? (scrollOffset / maxOffset) * trackRange : 0)
|
|
79
|
+
|
|
80
|
+
let dragging = $state(false)
|
|
81
|
+
let dragStartY = 0
|
|
82
|
+
let dragStartOffset = 0
|
|
83
|
+
function onThumbDown(e: PointerEvent) {
|
|
84
|
+
e.stopPropagation()
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
dragging = true
|
|
87
|
+
dragStartY = e.clientY
|
|
88
|
+
dragStartOffset = scrollOffset
|
|
89
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
90
|
+
}
|
|
91
|
+
function onThumbMove(e: PointerEvent) {
|
|
92
|
+
if (!dragging) return
|
|
93
|
+
const dy = e.clientY - dragStartY
|
|
94
|
+
scrollTo(dragStartOffset + (trackRange > 0 ? (dy / trackRange) * maxOffset : 0))
|
|
95
|
+
}
|
|
96
|
+
function onThumbUp(e: PointerEvent) {
|
|
97
|
+
dragging = false
|
|
98
|
+
releaseCapture(e)
|
|
99
|
+
}
|
|
100
|
+
function onTrackDown(e: PointerEvent) {
|
|
101
|
+
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
|
|
102
|
+
scrollByPx(e.clientY - rect.top < thumbTop ? -cfg.viewport() : cfg.viewport())
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --- Touch: drag the content to pan ---------------------------------------
|
|
106
|
+
let touchActive = false
|
|
107
|
+
let touchStartY = 0
|
|
108
|
+
let touchStartOffset = 0
|
|
109
|
+
function onContentDown(e: PointerEvent) {
|
|
110
|
+
if (e.pointerType !== 'touch') return
|
|
111
|
+
touchActive = true
|
|
112
|
+
touchStartY = e.clientY
|
|
113
|
+
touchStartOffset = scrollOffset
|
|
114
|
+
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
|
115
|
+
}
|
|
116
|
+
function onContentMove(e: PointerEvent) {
|
|
117
|
+
if (!touchActive) return
|
|
118
|
+
scrollTo(touchStartOffset - (e.clientY - touchStartY))
|
|
119
|
+
}
|
|
120
|
+
function onContentUp(e: PointerEvent) {
|
|
121
|
+
if (!touchActive) return
|
|
122
|
+
touchActive = false
|
|
123
|
+
releaseCapture(e)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function releaseCapture(e: PointerEvent) {
|
|
127
|
+
try {
|
|
128
|
+
;(e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId)
|
|
129
|
+
} catch {
|
|
130
|
+
/* capture may already be gone */
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Scroll the minimal amount to bring `[top, top+h]` fully into view. */
|
|
135
|
+
function ensureVisible(top: number, h: number) {
|
|
136
|
+
if (top < scrollOffset) scrollTo(top)
|
|
137
|
+
else if (top + h > scrollOffset + cfg.viewport()) scrollTo(top + h - cfg.viewport())
|
|
138
|
+
}
|
|
139
|
+
/** Bring a virtualizer row index into view (variable-height aware). */
|
|
140
|
+
function ensureIndexVisible(index: number) {
|
|
141
|
+
ensureVisible(cfg.virtualizer.getOffsetForIndex(index), cfg.virtualizer.getSizeForIndex(index))
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
get scrollOffset() {
|
|
146
|
+
return scrollOffset
|
|
147
|
+
},
|
|
148
|
+
get maxOffset() {
|
|
149
|
+
return maxOffset
|
|
150
|
+
},
|
|
151
|
+
get thumbH() {
|
|
152
|
+
return thumbH
|
|
153
|
+
},
|
|
154
|
+
get thumbTop() {
|
|
155
|
+
return thumbTop
|
|
156
|
+
},
|
|
157
|
+
get dragging() {
|
|
158
|
+
return dragging
|
|
159
|
+
},
|
|
160
|
+
scrollTo,
|
|
161
|
+
scrollByPx,
|
|
162
|
+
ensureVisible,
|
|
163
|
+
ensureIndexVisible,
|
|
164
|
+
onWheel,
|
|
165
|
+
onThumbDown,
|
|
166
|
+
onThumbMove,
|
|
167
|
+
onThumbUp,
|
|
168
|
+
onTrackDown,
|
|
169
|
+
onContentDown,
|
|
170
|
+
onContentMove,
|
|
171
|
+
onContentUp,
|
|
172
|
+
}
|
|
173
|
+
}
|