@svgrid/grid 2.6.21 → 2.6.23
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 +62 -0
- package/README.md +22 -0
- package/dist/GridMenus.svelte +201 -18
- package/dist/SvAutoComplete.svelte +4 -0
- package/dist/SvComboBox.svelte +5 -1
- package/dist/SvDateRangeInput.svelte +25 -3
- package/dist/SvDropDownList.svelte +4 -0
- package/dist/SvForm.svelte +8 -8
- package/dist/SvGrid.controller.svelte.d.ts +61 -9
- package/dist/SvGrid.controller.svelte.js +363 -123
- package/dist/SvGrid.css +222 -1
- package/dist/SvGrid.helpers.d.ts +55 -0
- package/dist/SvGrid.helpers.js +83 -0
- package/dist/SvGrid.svelte +255 -56
- package/dist/SvGrid.types.d.ts +146 -1
- package/dist/SvGridCellEditor.svelte +99 -17
- package/dist/SvGridSelect.svelte +6 -2
- package/dist/SvGridSelect.svelte.d.ts +1 -1
- package/dist/SvListBox.svelte +8 -0
- package/dist/SvMultiSelect.svelte +6 -2
- package/dist/SvMultiSelect.svelte.d.ts +1 -1
- package/dist/SvNumberInput.svelte +4 -0
- package/dist/SvRichCell.svelte +125 -0
- package/dist/SvRichCell.svelte.d.ts +15 -0
- package/dist/SvTextArea.svelte +5 -1
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/SvTreeSelect.svelte.d.ts +1 -1
- package/dist/builtin-editors.d.ts +2 -2
- package/dist/builtin-editors.js +19 -3
- package/dist/cdn/GridMenus-BzWXQjv3.js +600 -0
- package/dist/cdn/GridMenus-xr_rHx8F.js +601 -0
- package/dist/cdn/SvDateRangeInput-CaOuMs8O.js +198 -0
- package/dist/cdn/SvDateRangeInput-DMLKmGEc.js +199 -0
- package/dist/cdn/SvDateTimePicker-CCbDZNZB.js +816 -0
- package/dist/cdn/SvDateTimePicker-sonaH0oh.js +812 -0
- package/dist/cdn/SvGridCellEditor-BAPSC7EL.js +550 -0
- package/dist/cdn/SvGridCellEditor-BwzmUWtL.js +549 -0
- package/dist/cdn/column-resize-DsfNXMom.js +102 -0
- package/dist/cdn/date-format-BNii4zeD.js +1393 -0
- package/dist/cdn/date-format-BnnHlqGw.js +1395 -0
- package/dist/cdn/{editor-registry-CiI0xlKg.js → editor-registry-BhgE3S84.js} +51 -25
- package/dist/cdn/row-resize-BRcimkUT.js +95 -0
- package/dist/cdn/{src-DBel9wRZ.js → src-DGo7IHug.js} +10017 -9488
- package/dist/cdn/{src-BYq-qyrp.js → src-skQN5eqh.js} +7829 -7300
- package/dist/cdn/svgrid.js +14 -10
- package/dist/cdn/svgrid.svelte-external.js +14 -10
- package/dist/cell-values.d.ts +16 -0
- package/dist/cell-values.js +28 -0
- package/dist/clipboard.d.ts +22 -0
- package/dist/clipboard.js +405 -27
- package/dist/column-groups.js +1 -1
- package/dist/column-resize.d.ts +46 -0
- package/dist/column-resize.js +205 -0
- package/dist/columns.d.ts +0 -3
- package/dist/columns.js +0 -57
- package/dist/core.d.ts +19 -4
- package/dist/core.js +460 -119
- package/dist/editing.js +12 -7
- package/dist/editors/cell-editors.d.ts +11 -0
- package/dist/editors/cell-editors.js +18 -1
- package/dist/filtering/excel-filters.js +28 -0
- package/dist/grid-messages.d.ts +15 -0
- package/dist/grid-messages.js +15 -0
- package/dist/group-display.d.ts +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +10 -0
- package/dist/list-option.d.ts +6 -0
- package/dist/menus.js +41 -8
- package/dist/row-resize.d.ts +11 -0
- package/dist/row-resize.js +7 -1
- package/dist/sanitize-html.d.ts +37 -0
- package/dist/sanitize-html.js +234 -0
- package/dist/selection-bar-view.svelte.d.ts +25 -0
- package/dist/selection-bar-view.svelte.js +13 -0
- package/dist/selection.d.ts +2 -1
- package/dist/selection.js +133 -8
- package/dist/spreadsheet.d.ts +1 -1
- package/dist/spreadsheet.js +1 -1
- package/package.json +1 -1
- package/src/GridMenus.svelte +201 -18
- package/src/SvAutoComplete.svelte +4 -0
- package/src/SvComboBox.svelte +5 -1
- package/src/SvDateRangeInput.svelte +25 -3
- package/src/SvDropDownList.svelte +4 -0
- package/src/SvForm.svelte +8 -8
- package/src/SvGrid.controller.svelte.ts +404 -133
- package/src/SvGrid.css +222 -1
- package/src/SvGrid.helpers.ts +87 -0
- package/src/SvGrid.svelte +255 -56
- package/src/SvGrid.types.ts +153 -1
- package/src/SvGridCellEditor.svelte +99 -17
- package/src/SvGridSelect.svelte +6 -2
- package/src/SvListBox.svelte +8 -0
- package/src/SvMultiSelect.svelte +6 -2
- package/src/SvNumberInput.svelte +4 -0
- package/src/SvRichCell.svelte +125 -0
- package/src/SvTextArea.svelte +5 -1
- package/src/SvTreeSelect.svelte +6 -2
- package/src/builtin-editors.test.ts +18 -1
- package/src/builtin-editors.ts +19 -3
- package/src/cell-values.ts +30 -0
- package/src/clipboard.test.ts +93 -20
- package/src/clipboard.ts +433 -33
- package/src/column-groups.ts +1 -1
- package/src/column-resize.test.ts +381 -0
- package/src/column-resize.ts +227 -0
- package/src/columns.test.ts +0 -103
- package/src/columns.ts +0 -58
- package/src/core.aggregate.test.ts +134 -0
- package/src/core.filter.test.ts +156 -0
- package/src/core.grouping.test.ts +146 -0
- package/src/core.row-shape.test.ts +119 -0
- package/src/core.rowmodel-cache.test.ts +121 -0
- package/src/core.sort.test.ts +293 -0
- package/src/core.ts +516 -119
- package/src/date-string-column.test.ts +87 -0
- package/src/editing.test.ts +25 -0
- package/src/editing.ts +12 -9
- package/src/editor-block-prop.test.ts +132 -0
- package/src/editors/cell-editors.ts +27 -1
- package/src/filtering/excel-filters.ts +30 -0
- package/src/filtering/normalize-fast-path.test.ts +104 -0
- package/src/grid-messages.ts +34 -0
- package/src/group-display.ts +1 -1
- package/src/index.ts +25 -1
- package/src/list-option.ts +6 -0
- package/src/menus.test.ts +43 -0
- package/src/menus.ts +38 -8
- package/src/move-cells.test.ts +850 -0
- package/src/number-editor-literal.test.ts +84 -0
- package/src/number-editor.grid.test.ts +160 -0
- package/src/resize-props.test.ts +361 -0
- package/src/row-resize.test.ts +31 -0
- package/src/row-resize.ts +21 -3
- package/src/sanitize-html.test.ts +182 -0
- package/src/sanitize-html.ts +235 -0
- package/src/selection-bar-view.svelte.ts +36 -0
- package/src/selection.test.ts +157 -0
- package/src/selection.ts +135 -8
- package/src/spreadsheet.ts +1 -1
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +56 -10
- package/src/svgrid.in-suggest-snapshot.svelte.test.ts +91 -0
- package/src/svgrid.menu-scroll-close.test.ts +204 -0
- package/src/svgrid.selection-bar-seam.test.ts +185 -0
- package/src/svgrid.upsell-license.test.ts +117 -0
- package/dist/cdn/GridMenus-BfTAKn84.js +0 -488
- package/dist/cdn/GridMenus-C3bJd7w8.js +0 -489
- package/dist/cdn/SvDateTimePicker-CHnUkWcH.js +0 -2206
- package/dist/cdn/SvDateTimePicker-CRQH_U7E.js +0 -2201
- package/dist/cdn/SvGridCellEditor-BGUCzuPB.js +0 -523
- package/dist/cdn/SvGridCellEditor-G8IMWIws.js +0 -522
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typing a decimal into an `editorType: 'number'` cell.
|
|
3
|
+
*
|
|
4
|
+
* `<input type="number">` applies the HTML value-sanitization algorithm: the
|
|
5
|
+
* element only reports a value it can parse as a valid floating-point number.
|
|
6
|
+
* Every intermediate state on the way to "12.5" - "12." - is not one, so the
|
|
7
|
+
* element reports "" and the keystroke vanishes. The same applies to a lone
|
|
8
|
+
* "-" on the way to a negative, and to "1e" on the way to "1e3".
|
|
9
|
+
*
|
|
10
|
+
* jsdom implements that sanitization, so this is reproducible here rather than
|
|
11
|
+
* only in a browser.
|
|
12
|
+
*/
|
|
13
|
+
import { describe, expect, it } from 'vitest'
|
|
14
|
+
import {
|
|
15
|
+
getCellEditorInputType,
|
|
16
|
+
getEditorInputType,
|
|
17
|
+
isNumericEditorInput,
|
|
18
|
+
} from './SvGrid.helpers'
|
|
19
|
+
|
|
20
|
+
describe('what a bare number input does to intermediate values', () => {
|
|
21
|
+
// This is the browser behaviour the editor has to work around, pinned so the
|
|
22
|
+
// reasoning below cannot quietly stop being true.
|
|
23
|
+
const sanitized = (raw: string) => {
|
|
24
|
+
const el = document.createElement('input')
|
|
25
|
+
el.type = 'number'
|
|
26
|
+
el.value = raw
|
|
27
|
+
return el.value
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('throws away a trailing decimal point', () => {
|
|
31
|
+
expect(sanitized('12.')).toBe('')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('throws away a lone minus sign', () => {
|
|
35
|
+
expect(sanitized('-')).toBe('')
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('throws away a half-typed exponent', () => {
|
|
39
|
+
expect(sanitized('1e')).toBe('')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('keeps values that are already valid', () => {
|
|
43
|
+
expect(sanitized('12.5')).toBe('12.5')
|
|
44
|
+
expect(sanitized('-3')).toBe('-3')
|
|
45
|
+
expect(sanitized('1e3')).toBe('1e3')
|
|
46
|
+
})
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
describe('getCellEditorInputType', () => {
|
|
50
|
+
it('does NOT hand the number editor a type=number input', () => {
|
|
51
|
+
// The whole point: a text input keeps the raw keystrokes, and the value is
|
|
52
|
+
// coerced at commit by parseEditorValue - which the editor already did.
|
|
53
|
+
expect(getCellEditorInputType('number')).toBe('text')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('leaves every other editor on its native input type', () => {
|
|
57
|
+
for (const t of ['text', 'date', 'datetime', 'time', 'password', 'color'] as const) {
|
|
58
|
+
expect(getCellEditorInputType(t), t).toBe(getEditorInputType(t))
|
|
59
|
+
}
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('does not change the FILTER inputs, which share the other helper', () => {
|
|
63
|
+
// The filter row and filter menu still ask getEditorInputType. They have
|
|
64
|
+
// the same defect, but changing them is a separate decision - this pins
|
|
65
|
+
// that they were not swept along by accident.
|
|
66
|
+
expect(getEditorInputType('number')).toBe('number')
|
|
67
|
+
})
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
describe('isNumericEditorInput', () => {
|
|
71
|
+
it('accepts every state on the way to a number', () => {
|
|
72
|
+
// Rejecting these is the exact bug this replaces.
|
|
73
|
+
for (const v of ['', '-', '12', '12.', '12.5', '-3', '.5', '1e', '1e3', '1e-3']) {
|
|
74
|
+
expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(true)
|
|
75
|
+
}
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('rejects what can never become a number', () => {
|
|
79
|
+
// The guarantee type="number" used to provide, now that it is gone.
|
|
80
|
+
for (const v of ['abc', '12x', '12..5', '--3', '1 2', '12,5']) {
|
|
81
|
+
expect(isNumericEditorInput(v), JSON.stringify(v)).toBe(false)
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* End-to-end: typing a decimal into an `editorType: 'number'` cell keeps every
|
|
3
|
+
* keystroke, and the value still commits as a NUMBER.
|
|
4
|
+
*
|
|
5
|
+
* The unit half of this lives in `number-editor-literal.test.ts` - the browser
|
|
6
|
+
* sanitization that caused it, and the helpers that route around it. This
|
|
7
|
+
* drives the real editor, which is what actually regressed for users: typing
|
|
8
|
+
* "12.5" produced 125, because the "." was dropped and the digits ran together.
|
|
9
|
+
*/
|
|
10
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
11
|
+
import { mount, unmount } from 'svelte'
|
|
12
|
+
import SvGrid from './SvGrid.svelte'
|
|
13
|
+
import { createCoreRowModel, tableFeatures } from './index'
|
|
14
|
+
import type { ColumnDef, SvGridApi } from './index'
|
|
15
|
+
|
|
16
|
+
type Row = { id: number; label: string; amount: number }
|
|
17
|
+
const features = tableFeatures({})
|
|
18
|
+
const cols: ColumnDef<typeof features, Row>[] = [
|
|
19
|
+
{ field: 'label', header: 'Label', width: 160 },
|
|
20
|
+
{ field: 'amount', header: 'Amount', width: 140, editorType: 'number' },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
function mountGrid() {
|
|
24
|
+
return new Promise<{
|
|
25
|
+
api: SvGridApi<typeof features, Row>
|
|
26
|
+
target: HTMLElement
|
|
27
|
+
changes: Array<{ newValue: unknown }>
|
|
28
|
+
destroy: () => void
|
|
29
|
+
}>((res, rej) => {
|
|
30
|
+
const target = document.createElement('div')
|
|
31
|
+
document.body.appendChild(target)
|
|
32
|
+
const changes: Array<{ newValue: unknown }> = []
|
|
33
|
+
const app = mount(SvGrid, {
|
|
34
|
+
target,
|
|
35
|
+
props: {
|
|
36
|
+
data: [{ id: 1, label: 'One', amount: 5 }],
|
|
37
|
+
columns: cols,
|
|
38
|
+
features,
|
|
39
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
40
|
+
getRowId: (r: Row) => String(r.id),
|
|
41
|
+
containerHeight: 200,
|
|
42
|
+
virtualization: false,
|
|
43
|
+
editable: true,
|
|
44
|
+
onCellValueChange: (e: { newValue: unknown }) => changes.push(e),
|
|
45
|
+
onApiReady(api: SvGridApi<typeof features, Row>) {
|
|
46
|
+
res({ api, target, changes, destroy: () => { unmount(app); target.remove() } })
|
|
47
|
+
},
|
|
48
|
+
} as any,
|
|
49
|
+
})
|
|
50
|
+
queueMicrotask(() => { if (!target.querySelector('[role="grid"]')) rej(new Error('no grid')) })
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Start editing the amount cell and return its input, once the lazy chunk lands. */
|
|
55
|
+
async function editAmount(api: SvGridApi<typeof features, Row>, target: HTMLElement) {
|
|
56
|
+
api.startEditing(0, 'amount')
|
|
57
|
+
await vi.waitFor(() => {
|
|
58
|
+
expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
|
|
59
|
+
})
|
|
60
|
+
return target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Type `text` one character at a time, as a user does. */
|
|
64
|
+
function typeInto(input: HTMLInputElement, text: string) {
|
|
65
|
+
let sofar = ''
|
|
66
|
+
for (const ch of text) {
|
|
67
|
+
sofar += ch
|
|
68
|
+
input.value = sofar
|
|
69
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
70
|
+
// Read back what survived - the element may have rewritten it.
|
|
71
|
+
sofar = input.value
|
|
72
|
+
}
|
|
73
|
+
return input.value
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('number editor - decimals survive being typed', () => {
|
|
77
|
+
it('keeps the trailing decimal point mid-entry', async () => {
|
|
78
|
+
const { api, target, destroy } = await mountGrid()
|
|
79
|
+
try {
|
|
80
|
+
const input = await editAmount(api, target)
|
|
81
|
+
// The bug: with type="number" this read back "" and the dot was gone.
|
|
82
|
+
expect(typeInto(input, '12.')).toBe('12.')
|
|
83
|
+
} finally {
|
|
84
|
+
destroy()
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('commits 12.5 as the number 12.5, not 125', async () => {
|
|
89
|
+
const { api, target, changes, destroy } = await mountGrid()
|
|
90
|
+
try {
|
|
91
|
+
const input = await editAmount(api, target)
|
|
92
|
+
typeInto(input, '12.5')
|
|
93
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
94
|
+
|
|
95
|
+
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
|
|
96
|
+
expect(changes.at(-1)!.newValue).toBe(12.5)
|
|
97
|
+
expect(typeof changes.at(-1)!.newValue).toBe('number')
|
|
98
|
+
} finally {
|
|
99
|
+
destroy()
|
|
100
|
+
}
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('keeps a lone minus sign on the way to a negative', async () => {
|
|
104
|
+
const { api, target, changes, destroy } = await mountGrid()
|
|
105
|
+
try {
|
|
106
|
+
const input = await editAmount(api, target)
|
|
107
|
+
expect(typeInto(input, '-')).toBe('-')
|
|
108
|
+
typeInto(input, '-4.25')
|
|
109
|
+
input.dispatchEvent(new Event('blur', { bubbles: true }))
|
|
110
|
+
|
|
111
|
+
await vi.waitFor(() => expect(changes.length).toBeGreaterThan(0))
|
|
112
|
+
expect(changes.at(-1)!.newValue).toBe(-4.25)
|
|
113
|
+
} finally {
|
|
114
|
+
destroy()
|
|
115
|
+
}
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
it('carries the mobile decimal keypad hint', async () => {
|
|
119
|
+
// Losing type="number" also loses the numeric soft keyboard, which is what
|
|
120
|
+
// inputmode restores.
|
|
121
|
+
const { api, target, destroy } = await mountGrid()
|
|
122
|
+
try {
|
|
123
|
+
const input = await editAmount(api, target)
|
|
124
|
+
expect(input.getAttribute('type')).toBe('text')
|
|
125
|
+
expect(input.getAttribute('inputmode')).toBe('decimal')
|
|
126
|
+
} finally {
|
|
127
|
+
destroy()
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('refuses characters that can never be part of a number', async () => {
|
|
132
|
+
// type="number" used to block these; a text input does not, so the editor
|
|
133
|
+
// has to. Without the guard "12x" would reach the commit and coerce to null.
|
|
134
|
+
const { api, target, destroy } = await mountGrid()
|
|
135
|
+
try {
|
|
136
|
+
const input = await editAmount(api, target)
|
|
137
|
+
typeInto(input, '12')
|
|
138
|
+
input.value = '12x'
|
|
139
|
+
input.dispatchEvent(new Event('input', { bubbles: true }))
|
|
140
|
+
expect(input.value).toBe('12')
|
|
141
|
+
} finally {
|
|
142
|
+
destroy()
|
|
143
|
+
}
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('leaves a text column alone', async () => {
|
|
147
|
+
const { api, target, destroy } = await mountGrid()
|
|
148
|
+
try {
|
|
149
|
+
api.startEditing(0, 'label')
|
|
150
|
+
await vi.waitFor(() => {
|
|
151
|
+
expect(target.querySelector('.sv-grid-cell-editing input')).not.toBeNull()
|
|
152
|
+
})
|
|
153
|
+
const input = target.querySelector('.sv-grid-cell-editing input') as HTMLInputElement
|
|
154
|
+
expect(input.getAttribute('inputmode')).toBeNull()
|
|
155
|
+
expect(typeInto(input, 'hello.')).toBe('hello.')
|
|
156
|
+
} finally {
|
|
157
|
+
destroy()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
})
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `columnResize` / `rowResize` props, against a REAL mounted `<SvGrid>`.
|
|
3
|
+
*
|
|
4
|
+
* Both default to OFF and both are opt-in, so they are tested together and the
|
|
5
|
+
* last block asserts they behave identically.
|
|
6
|
+
*
|
|
7
|
+
* Two things make "the prop is on" and "the affordance exists" separate claims
|
|
8
|
+
* here, and both are why these tests double-`settle()`:
|
|
9
|
+
*
|
|
10
|
+
* - Neither affordance is rendered by the component. Each is injected by an
|
|
11
|
+
* action through a MutationObserver.
|
|
12
|
+
* - Neither action is imported statically. Both arrive via `import()`, so the
|
|
13
|
+
* handles land a microtask after the first paint.
|
|
14
|
+
*
|
|
15
|
+
* `columnResize` off-by-default is a deliberate behaviour CHANGE - column
|
|
16
|
+
* resizing used to be unconditional - so the assertion pinning that is load
|
|
17
|
+
* bearing, not a restatement of the obvious.
|
|
18
|
+
*
|
|
19
|
+
* jsdom does no layout, so the drag arithmetic is only sanity-checked here; it
|
|
20
|
+
* is covered properly against synthetic DOM in row-resize.test.ts and
|
|
21
|
+
* column-resize.test.ts. What this file checks is the wiring.
|
|
22
|
+
*/
|
|
23
|
+
import { describe, expect, it, beforeEach } from 'vitest'
|
|
24
|
+
import { mount, unmount } from 'svelte'
|
|
25
|
+
import SvGrid from './SvGrid.svelte'
|
|
26
|
+
import { tableFeatures, createCoreRowModel } from './index'
|
|
27
|
+
import type { ColumnDef } from './index'
|
|
28
|
+
|
|
29
|
+
type Row = { id: string; name: string; team: string }
|
|
30
|
+
|
|
31
|
+
const features = tableFeatures({})
|
|
32
|
+
|
|
33
|
+
const rows: Row[] = [
|
|
34
|
+
{ id: 'r1', name: 'Ada Lovelace', team: 'Platform' },
|
|
35
|
+
{ id: 'r2', name: 'Grace Hopper', team: 'Compilers' },
|
|
36
|
+
{ id: 'r3', name: 'Barbara Liskov', team: 'Platform' },
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
const cols: ColumnDef<typeof features, Row>[] = [
|
|
40
|
+
{ field: 'name', header: 'Name', width: 200 },
|
|
41
|
+
{ field: 'team', header: 'Team', width: 140 },
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
const mounted: Array<() => void> = []
|
|
45
|
+
|
|
46
|
+
function mountGrid(extraProps: Record<string, unknown> = {}) {
|
|
47
|
+
const target = document.createElement('div')
|
|
48
|
+
document.body.appendChild(target)
|
|
49
|
+
const app = mount(SvGrid, {
|
|
50
|
+
target,
|
|
51
|
+
props: {
|
|
52
|
+
data: rows,
|
|
53
|
+
columns: cols,
|
|
54
|
+
features,
|
|
55
|
+
_rowModels: { coreRowModel: createCoreRowModel() },
|
|
56
|
+
containerHeight: 300,
|
|
57
|
+
// Virtualization off so every row is in the DOM to be counted.
|
|
58
|
+
virtualization: false,
|
|
59
|
+
columnVirtualization: false,
|
|
60
|
+
...extraProps,
|
|
61
|
+
} as never,
|
|
62
|
+
})
|
|
63
|
+
mounted.push(() => {
|
|
64
|
+
unmount(app)
|
|
65
|
+
target.remove()
|
|
66
|
+
})
|
|
67
|
+
return target
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Let the row model + effects settle before reading the DOM. */
|
|
71
|
+
const settle = () => new Promise((r) => setTimeout(r, 0))
|
|
72
|
+
|
|
73
|
+
beforeEach(() => {
|
|
74
|
+
while (mounted.length) mounted.pop()!()
|
|
75
|
+
document.body.innerHTML = ''
|
|
76
|
+
if (!HTMLElement.prototype.setPointerCapture) {
|
|
77
|
+
HTMLElement.prototype.setPointerCapture = function () {}
|
|
78
|
+
}
|
|
79
|
+
if (!HTMLElement.prototype.releasePointerCapture) {
|
|
80
|
+
HTMLElement.prototype.releasePointerCapture = function () {}
|
|
81
|
+
}
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
const handles = (t: HTMLElement) => t.querySelectorAll('.sv-grid-resize-handle').length
|
|
85
|
+
const strips = (t: HTMLElement) => t.querySelectorAll('.sv-grid-row-resize-handle').length
|
|
86
|
+
|
|
87
|
+
describe('columnResize prop', () => {
|
|
88
|
+
it('injects no handles when the prop is absent', async () => {
|
|
89
|
+
const target = mountGrid()
|
|
90
|
+
await settle()
|
|
91
|
+
await settle()
|
|
92
|
+
// Off by default. Column resizing used to be unconditional, so this is the
|
|
93
|
+
// assertion that pins the deliberate behaviour change.
|
|
94
|
+
expect(handles(target)).toBe(0)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('injects a handle per column when true', async () => {
|
|
98
|
+
const target = mountGrid({ columnResize: true })
|
|
99
|
+
await settle()
|
|
100
|
+
// The action is fetched through import(), so the handles land a tick later
|
|
101
|
+
// than the first paint - a second settle, not a rendering bug.
|
|
102
|
+
await settle()
|
|
103
|
+
expect(handles(target)).toBe(cols.length)
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('injects no handles when explicitly false', async () => {
|
|
107
|
+
const target = mountGrid({ columnResize: false })
|
|
108
|
+
await settle()
|
|
109
|
+
await settle()
|
|
110
|
+
expect(handles(target)).toBe(0)
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('leaves the headers themselves intact when off', async () => {
|
|
114
|
+
// Only the drag affordance is absent - the column still renders, still
|
|
115
|
+
// sorts, still has its accessible name.
|
|
116
|
+
const target = mountGrid({ columnResize: false })
|
|
117
|
+
await settle()
|
|
118
|
+
expect(target.querySelectorAll('.sv-grid-column').length).toBeGreaterThanOrEqual(cols.length)
|
|
119
|
+
expect(target.textContent).toContain('Name')
|
|
120
|
+
expect(target.textContent).toContain('Team')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('applies a dragged width to the column', async () => {
|
|
124
|
+
const target = mountGrid({ columnResize: true })
|
|
125
|
+
await settle()
|
|
126
|
+
await settle()
|
|
127
|
+
|
|
128
|
+
const handle = target.querySelector<HTMLElement>('.sv-grid-resize-handle')!
|
|
129
|
+
const th = handle.closest<HTMLElement>('th.sv-grid-column')!
|
|
130
|
+
const opts = { bubbles: true, cancelable: true, pointerId: 1 }
|
|
131
|
+
handle.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientX: 0 } as PointerEventInit))
|
|
132
|
+
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, clientX: 60 } as PointerEventInit))
|
|
133
|
+
await settle()
|
|
134
|
+
|
|
135
|
+
// Name starts at 200 (see `cols`), so a +60 drag lands at 260.
|
|
136
|
+
expect(th.style.width).toContain('260')
|
|
137
|
+
})
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
describe('rowResize prop', () => {
|
|
141
|
+
it('injects no strips when the prop is absent', async () => {
|
|
142
|
+
const target = mountGrid()
|
|
143
|
+
await settle()
|
|
144
|
+
expect(strips(target)).toBe(0)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('injects a strip per body row when on', async () => {
|
|
148
|
+
const target = mountGrid({ rowResize: true })
|
|
149
|
+
await settle()
|
|
150
|
+
// The MutationObserver decorates asynchronously; give it a turn.
|
|
151
|
+
await settle()
|
|
152
|
+
expect(strips(target)).toBe(rows.length)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('works without a gutter column, via the row anchor', async () => {
|
|
156
|
+
// No showRowNumbers, no `sv-row-gutter` cellClass. Before the anchor
|
|
157
|
+
// fallback this rendered zero strips and the prop did nothing at all.
|
|
158
|
+
const target = mountGrid({ rowResize: true })
|
|
159
|
+
await settle()
|
|
160
|
+
await settle()
|
|
161
|
+
const first = target.querySelector<HTMLElement>('.sv-grid-row-resize-handle')
|
|
162
|
+
expect(first).not.toBeNull()
|
|
163
|
+
expect(first!.closest('td')).not.toBeNull()
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('gives each strip a keyboard-reachable separator role', async () => {
|
|
167
|
+
const target = mountGrid({ rowResize: true })
|
|
168
|
+
await settle()
|
|
169
|
+
await settle()
|
|
170
|
+
const strip = target.querySelector<HTMLElement>('.sv-grid-row-resize-handle')!
|
|
171
|
+
expect(strip.getAttribute('role')).toBe('separator')
|
|
172
|
+
expect(strip.getAttribute('aria-orientation')).toBe('horizontal')
|
|
173
|
+
expect(strip.tabIndex).toBe(0)
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
it('is suppressed by autoRowHeight, which owns the height itself', async () => {
|
|
177
|
+
// Both want to set the row height. Letting them both run means the next
|
|
178
|
+
// measurement pass silently discards whatever the user dragged.
|
|
179
|
+
const target = mountGrid({ rowResize: true, autoRowHeight: true })
|
|
180
|
+
await settle()
|
|
181
|
+
await settle()
|
|
182
|
+
expect(strips(target)).toBe(0)
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('applies a dragged height to the row', async () => {
|
|
186
|
+
const target = mountGrid({ rowResize: true, rowHeight: 30 })
|
|
187
|
+
await settle()
|
|
188
|
+
await settle()
|
|
189
|
+
|
|
190
|
+
const strip = target.querySelector<HTMLElement>('.sv-grid-row-resize-handle')!
|
|
191
|
+
const tr = strip.closest<HTMLTableRowElement>('tr.sv-grid-row')!
|
|
192
|
+
// jsdom reports a zero rect, so the drag needs a real starting height.
|
|
193
|
+
tr.getBoundingClientRect = () =>
|
|
194
|
+
({ height: 30, width: 300, top: 0, left: 0, right: 300, bottom: 30, x: 0, y: 0, toJSON() {} }) as DOMRect
|
|
195
|
+
|
|
196
|
+
const opts = { bubbles: true, cancelable: true, pointerId: 1 }
|
|
197
|
+
strip.dispatchEvent(new PointerEvent('pointerdown', { ...opts, clientY: 0 } as PointerEventInit))
|
|
198
|
+
window.dispatchEvent(new PointerEvent('pointerup', { ...opts, clientY: 44 } as PointerEventInit))
|
|
199
|
+
await settle()
|
|
200
|
+
|
|
201
|
+
expect(tr.style.height).toBe('74px')
|
|
202
|
+
})
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The two props are meant to be interchangeable in every way a consumer can
|
|
207
|
+
* touch them: same import, same shape, same opt-in, same runtime override.
|
|
208
|
+
* Asserted rather than assumed, because they were built at different times and
|
|
209
|
+
* `rowResize` reads through the controller while `columnResize` reads in the
|
|
210
|
+
* view - two paths that could easily drift apart.
|
|
211
|
+
*/
|
|
212
|
+
describe('rowResize / columnResize symmetry', () => {
|
|
213
|
+
it('are exported from the package the same way', async () => {
|
|
214
|
+
const pkg = (await import('./index')) as Record<string, unknown>
|
|
215
|
+
expect(typeof pkg.rowResize).toBe('function')
|
|
216
|
+
expect(typeof pkg.columnResize).toBe('function')
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
it('both default to off', async () => {
|
|
220
|
+
const target = mountGrid()
|
|
221
|
+
await settle()
|
|
222
|
+
await settle()
|
|
223
|
+
expect(handles(target)).toBe(0)
|
|
224
|
+
expect(strips(target)).toBe(0)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('both turn on with an explicit true', async () => {
|
|
228
|
+
const target = mountGrid({ columnResize: true, rowResize: true })
|
|
229
|
+
await settle()
|
|
230
|
+
await settle()
|
|
231
|
+
expect(handles(target)).toBe(cols.length)
|
|
232
|
+
expect(strips(target)).toBe(rows.length)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('both turn on at runtime through api.setOption', async () => {
|
|
236
|
+
// `columnResize` is read in the view and `rowResize` in the controller, but
|
|
237
|
+
// both go through the same override proxy - so both must react here.
|
|
238
|
+
let api: { setOption: (k: string, v: unknown) => void } | null = null
|
|
239
|
+
const target = mountGrid({ onApiReady: (next: unknown) => (api = next as never) })
|
|
240
|
+
await settle()
|
|
241
|
+
expect(handles(target)).toBe(0)
|
|
242
|
+
expect(strips(target)).toBe(0)
|
|
243
|
+
|
|
244
|
+
api!.setOption('columnResize', true)
|
|
245
|
+
api!.setOption('rowResize', true)
|
|
246
|
+
await settle()
|
|
247
|
+
await settle()
|
|
248
|
+
expect(handles(target)).toBe(cols.length)
|
|
249
|
+
expect(strips(target)).toBe(rows.length)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it('both turn back off through api.setOption', async () => {
|
|
253
|
+
let api: { setOption: (k: string, v: unknown) => void } | null = null
|
|
254
|
+
const target = mountGrid({
|
|
255
|
+
columnResize: true,
|
|
256
|
+
rowResize: true,
|
|
257
|
+
onApiReady: (next: unknown) => (api = next as never),
|
|
258
|
+
})
|
|
259
|
+
await settle()
|
|
260
|
+
await settle()
|
|
261
|
+
expect(handles(target)).toBe(cols.length)
|
|
262
|
+
expect(strips(target)).toBe(rows.length)
|
|
263
|
+
|
|
264
|
+
api!.setOption('columnResize', false)
|
|
265
|
+
api!.setOption('rowResize', false)
|
|
266
|
+
await settle()
|
|
267
|
+
await settle()
|
|
268
|
+
expect(handles(target)).toBe(0)
|
|
269
|
+
expect(strips(target)).toBe(0)
|
|
270
|
+
})
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
describe('ColumnDef.resizable', () => {
|
|
274
|
+
const mixed = [
|
|
275
|
+
{ field: 'name', header: 'Name', width: 200 },
|
|
276
|
+
{ field: 'team', header: 'Team', width: 140, resizable: false },
|
|
277
|
+
] as ColumnDef<typeof features, Row>[]
|
|
278
|
+
|
|
279
|
+
it('drops the handle for the column that opted out', async () => {
|
|
280
|
+
const target = mountGrid({ columnResize: true, columns: mixed })
|
|
281
|
+
await settle()
|
|
282
|
+
await settle()
|
|
283
|
+
expect(handles(target)).toBe(1)
|
|
284
|
+
const th = target
|
|
285
|
+
.querySelector<HTMLElement>('.sv-grid-resize-handle')!
|
|
286
|
+
.closest<HTMLElement>('th')!
|
|
287
|
+
expect(th.dataset.svgridHeaderCol).toBe('name')
|
|
288
|
+
})
|
|
289
|
+
|
|
290
|
+
it('narrows columnResize rather than enabling anything', async () => {
|
|
291
|
+
// With the grid-wide prop off, `resizable: true` must not bring a handle
|
|
292
|
+
// back - the column option only ever subtracts.
|
|
293
|
+
const target = mountGrid({
|
|
294
|
+
columns: [
|
|
295
|
+
{ field: 'name', header: 'Name', width: 200, resizable: true },
|
|
296
|
+
{ field: 'team', header: 'Team', width: 140 },
|
|
297
|
+
] as ColumnDef<typeof features, Row>[],
|
|
298
|
+
})
|
|
299
|
+
await settle()
|
|
300
|
+
await settle()
|
|
301
|
+
expect(handles(target)).toBe(0)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
it('leaves every column resizable when none opts out', async () => {
|
|
305
|
+
const target = mountGrid({ columnResize: true })
|
|
306
|
+
await settle()
|
|
307
|
+
await settle()
|
|
308
|
+
expect(handles(target)).toBe(cols.length)
|
|
309
|
+
})
|
|
310
|
+
})
|
|
311
|
+
|
|
312
|
+
describe('rowResize brings in the row header column', () => {
|
|
313
|
+
const gutters = (t: HTMLElement) => t.querySelectorAll('.sv-grid-row-number-cell').length
|
|
314
|
+
|
|
315
|
+
it('shows the row-number gutter when rowResize is on', async () => {
|
|
316
|
+
// The strip lives in the row header, which is where a spreadsheet puts it.
|
|
317
|
+
// Without a gutter there is nothing that reads as a grab target.
|
|
318
|
+
const target = mountGrid({ rowResize: true })
|
|
319
|
+
await settle()
|
|
320
|
+
await settle()
|
|
321
|
+
expect(gutters(target)).toBe(rows.length)
|
|
322
|
+
const strip = target.querySelector<HTMLElement>('.sv-grid-row-resize-handle')!
|
|
323
|
+
expect(strip.closest('.sv-grid-row-number-cell')).not.toBeNull()
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
it('shows no gutter when rowResize is off', async () => {
|
|
327
|
+
const target = mountGrid()
|
|
328
|
+
await settle()
|
|
329
|
+
await settle()
|
|
330
|
+
expect(gutters(target)).toBe(0)
|
|
331
|
+
})
|
|
332
|
+
|
|
333
|
+
it('lets an explicit showRowNumbers={false} win', async () => {
|
|
334
|
+
// Turning the gutter on is a default, not a takeover. The strip falls back
|
|
335
|
+
// to the row's first cell so the prop still does something.
|
|
336
|
+
const target = mountGrid({ rowResize: true, showRowNumbers: false })
|
|
337
|
+
await settle()
|
|
338
|
+
await settle()
|
|
339
|
+
expect(gutters(target)).toBe(0)
|
|
340
|
+
expect(strips(target)).toBe(rows.length)
|
|
341
|
+
const strip = target.querySelector<HTMLElement>('.sv-grid-row-resize-handle')!
|
|
342
|
+
expect(strip.closest('td')).not.toBeNull()
|
|
343
|
+
})
|
|
344
|
+
|
|
345
|
+
it('does not disturb a grid that already asked for the gutter', async () => {
|
|
346
|
+
const target = mountGrid({ rowResize: true, showRowNumbers: true })
|
|
347
|
+
await settle()
|
|
348
|
+
await settle()
|
|
349
|
+
expect(gutters(target)).toBe(rows.length)
|
|
350
|
+
})
|
|
351
|
+
|
|
352
|
+
it('brings no gutter when autoRowHeight has suppressed rowResize', async () => {
|
|
353
|
+
// rowResizeOn is false there, so the gutter would be a column with no
|
|
354
|
+
// purpose - the gate has to follow the resolved state, not the raw prop.
|
|
355
|
+
const target = mountGrid({ rowResize: true, autoRowHeight: true })
|
|
356
|
+
await settle()
|
|
357
|
+
await settle()
|
|
358
|
+
expect(gutters(target)).toBe(0)
|
|
359
|
+
expect(strips(target)).toBe(0)
|
|
360
|
+
})
|
|
361
|
+
})
|
package/src/row-resize.test.ts
CHANGED
|
@@ -130,6 +130,37 @@ describe('rowResize - strip injection', () => {
|
|
|
130
130
|
action.destroy()
|
|
131
131
|
})
|
|
132
132
|
|
|
133
|
+
it("anchor 'row' falls back to the first body cell when there is no gutter", () => {
|
|
134
|
+
// What `<SvGrid rowResize>` relies on: most grids have no row-number
|
|
135
|
+
// column, and without the fallback the prop would be inert on them.
|
|
136
|
+
const { host, rows } = buildGrid([{ gutter: false }, { gutter: false }])
|
|
137
|
+
track(host)
|
|
138
|
+
const action = rowResize(host, { onResize: vi.fn(), anchor: 'row' })
|
|
139
|
+
expect(stripOf(rows[0]!)).not.toBeNull()
|
|
140
|
+
expect(stripOf(rows[1]!)).not.toBeNull()
|
|
141
|
+
// The strip hangs off the row's first cell, since that is all there is.
|
|
142
|
+
expect(stripOf(rows[0]!)!.parentElement).toBe(rows[0]!.querySelector('.sv-grid-cell'))
|
|
143
|
+
action.destroy()
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it("anchor 'row' still prefers a real gutter when the row has one", () => {
|
|
147
|
+
const { host, rows } = buildGrid([{ gutter: true }])
|
|
148
|
+
track(host)
|
|
149
|
+
const action = rowResize(host, { onResize: vi.fn(), anchor: 'row' })
|
|
150
|
+
expect(stripOf(rows[0]!)!.parentElement).toBe(gutterOf(rows[0]!))
|
|
151
|
+
action.destroy()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it("defaults to anchor 'gutter', leaving gutterless rows alone", () => {
|
|
155
|
+
// The exported action's existing contract. Adding the fallback must not
|
|
156
|
+
// start decorating rows for consumers who never asked for it.
|
|
157
|
+
const { host, rows } = buildGrid([{ gutter: false }])
|
|
158
|
+
track(host)
|
|
159
|
+
const action = rowResize(host, { onResize: vi.fn() })
|
|
160
|
+
expect(stripOf(rows[0]!)).toBeNull()
|
|
161
|
+
action.destroy()
|
|
162
|
+
})
|
|
163
|
+
|
|
133
164
|
it('also injects a strip into the built-in row-number gutter (showRowNumbers)', () => {
|
|
134
165
|
// The native gutter cell is `.sv-grid-cell.sv-grid-row-number-cell`, NOT
|
|
135
166
|
// `.sv-row-gutter`; the action must recognise it so `showRowNumbers` grids
|
package/src/row-resize.ts
CHANGED
|
@@ -39,6 +39,17 @@ export type RowResizeOptions = {
|
|
|
39
39
|
max?: number
|
|
40
40
|
/** When true, the action removes its strips and ignores events. */
|
|
41
41
|
disabled?: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Where the drag strip is anchored.
|
|
44
|
+
* - `'gutter'` (default) - only rows that have a row-header gutter cell get
|
|
45
|
+
* a strip: the built-in row-number column (`showRowNumbers`) or a column
|
|
46
|
+
* tagged `cellClass: 'sv-row-gutter'`. Rows without one are skipped, so on
|
|
47
|
+
* a plain grid the action does nothing.
|
|
48
|
+
* - `'row'` - use the gutter when there is one, otherwise fall back to the
|
|
49
|
+
* row's first body cell. This is what `<SvGrid rowResize>` passes, so the
|
|
50
|
+
* prop works without also asking for a gutter column.
|
|
51
|
+
*/
|
|
52
|
+
anchor?: 'gutter' | 'row'
|
|
42
53
|
}
|
|
43
54
|
|
|
44
55
|
type ActiveDrag = {
|
|
@@ -150,9 +161,16 @@ export function rowResize(node: HTMLElement, opts: RowResizeOptions) {
|
|
|
150
161
|
// A row-header gutter cell: either a user column tagged
|
|
151
162
|
// `sv-row-gutter`, or the grid's built-in row-number gutter
|
|
152
163
|
// (`showRowNumbers`), whose cell is `.sv-grid-row-number-cell`.
|
|
153
|
-
const gutter =
|
|
154
|
-
|
|
155
|
-
|
|
164
|
+
const gutter =
|
|
165
|
+
tr.querySelector<HTMLTableCellElement>(
|
|
166
|
+
'.sv-grid-cell.sv-row-gutter, .sv-grid-cell.sv-grid-row-number-cell',
|
|
167
|
+
) ??
|
|
168
|
+
// `anchor: 'row'` - no gutter on this grid, so hang the strip off the
|
|
169
|
+
// first body cell instead. Without this a grid with no row-number
|
|
170
|
+
// column would silently not resize.
|
|
171
|
+
(current.anchor === 'row'
|
|
172
|
+
? tr.querySelector<HTMLTableCellElement>('.sv-grid-cell')
|
|
173
|
+
: null)
|
|
156
174
|
if (!gutter) continue
|
|
157
175
|
if (gutter.querySelector(`:scope > .${STRIP_CLASS}`)) continue
|
|
158
176
|
// Anchor the strip to the gutter cell + allow it to overflow
|