@svgrid/grid 2.2.31 → 2.2.32
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 +1 -1
- package/dist/SvAvatar.svelte +1 -1
- package/dist/SvCheckBox.svelte +2 -1
- package/dist/SvCircularProgress.svelte +1 -1
- package/dist/SvGrid.controller.svelte.d.ts +20 -0
- package/dist/SvGrid.controller.svelte.js +358 -18
- package/dist/SvGrid.css +103 -3
- package/dist/SvGrid.svelte +162 -10
- package/dist/SvGrid.types.d.ts +81 -0
- package/dist/SvGridDropdown.svelte +38 -6
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvGridSelect.svelte +12 -3
- package/dist/SvGridSelect.svelte.d.ts +6 -0
- package/dist/SvGroupCell.svelte +9 -2
- package/dist/SvMultiSelect.svelte +12 -3
- package/dist/SvMultiSelect.svelte.d.ts +6 -0
- package/dist/SvPasswordInput.svelte +1 -1
- package/dist/SvProgress.svelte +1 -1
- package/dist/SvRating.svelte +1 -1
- package/dist/SvResult.svelte +1 -1
- package/dist/SvRichText.svelte +31 -16
- package/dist/SvRichText.svelte.d.ts +9 -0
- package/dist/SvSwitchButton.svelte +2 -1
- package/dist/SvToaster.svelte +1 -1
- package/dist/SvTree.svelte +9 -2
- package/dist/SvTree.svelte.d.ts +5 -0
- package/dist/SvTreeSelect.svelte +6 -2
- package/dist/build-api.js +16 -0
- package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-C6ixSGWW.js} +33 -33
- package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-Cyk3qpJ7.js} +33 -33
- package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-CdqaP61H.js} +2 -1
- package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-Dh-2ej8j.js} +92 -87
- package/dist/cdn/{src-BLJfdyL0.js → src-BgHjWF0Q.js} +4193 -3837
- package/dist/cdn/{src-DYDCiC0D.js → src-CgD-wiuS.js} +5956 -5602
- package/dist/cdn/svgrid.js +8 -8
- package/dist/cdn/svgrid.svelte-external.js +8 -8
- package/dist/cell-render.d.ts +1 -0
- package/dist/cell-render.js +115 -1
- package/dist/cell-values.js +5 -0
- package/dist/core.d.ts +80 -9
- package/dist/core.js +89 -0
- package/dist/editor-contract.d.ts +12 -5
- package/dist/editor-registry.d.ts +18 -7
- package/dist/editor-registry.js +10 -1
- package/dist/group-display.d.ts +39 -0
- package/dist/group-display.js +52 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/keyboard-handlers.js +16 -0
- package/dist/recurrence.js +3 -2
- package/dist/svgrid-wrapper.types.d.ts +8 -2
- package/dist/themes/index.d.ts +10 -0
- package/dist/themes/index.js +26 -0
- package/package.json +1 -1
- package/src/SvAccordion.test.ts +5 -5
- package/src/SvAvatar.svelte +1 -1
- package/src/SvButtonGroup.test.ts +6 -6
- package/src/SvCheckBox.svelte +2 -1
- package/src/SvCircularProgress.svelte +1 -1
- package/src/SvFileUpload.test.ts +1 -1
- package/src/SvGrid.controller.svelte.ts +367 -17
- package/src/SvGrid.css +103 -3
- package/src/SvGrid.svelte +162 -10
- package/src/SvGrid.types.ts +81 -0
- package/src/SvGridDropdown.svelte +38 -6
- package/src/SvGridSelect.svelte +12 -3
- package/src/SvGroupCell.svelte +9 -2
- package/src/SvMultiSelect.svelte +12 -3
- package/src/SvPasswordInput.svelte +1 -1
- package/src/SvProgress.svelte +1 -1
- package/src/SvRating.svelte +1 -1
- package/src/SvResult.svelte +1 -1
- package/src/SvRichText.svelte +31 -16
- package/src/SvSwitchButton.svelte +2 -1
- package/src/SvToaster.svelte +1 -1
- package/src/SvTree.svelte +9 -2
- package/src/SvTreeSelect.svelte +6 -2
- package/src/build-api.ts +15 -0
- package/src/builtin-editors.test.ts +3 -1
- package/src/cell-render.test.ts +27 -3
- package/src/cell-render.ts +122 -1
- package/src/cell-values.ts +4 -0
- package/src/chart-export.test.ts +2 -2
- package/src/clipboard.test.ts +0 -2
- package/src/core.ts +157 -8
- package/src/editing.test.ts +1 -1
- package/src/editor-contract.ts +12 -5
- package/src/editor-registry.grid.test.ts +48 -0
- package/src/editor-registry.test.ts +3 -1
- package/src/editor-registry.ts +23 -7
- package/src/facet-buckets.test.ts +33 -33
- package/src/group-display.test.ts +91 -1
- package/src/group-display.ts +83 -0
- package/src/index.ts +9 -0
- package/src/keyboard-handlers.ts +17 -0
- package/src/list-power.test.ts +2 -2
- package/src/menus.test.ts +4 -4
- package/src/named-views.coverage.test.ts +3 -3
- package/src/recurrence.ts +3 -2
- package/src/row-resize.test.ts +40 -40
- package/src/scheduler-model.ts +1 -1
- package/src/server-data-source.coverage.test.ts +1 -1
- package/src/server-group-model.test.ts +6 -6
- package/src/spreadsheet.test.ts +0 -1
- package/src/svgrid-wrapper.types.ts +11 -9
- package/src/svgrid.async-editor-options.test.ts +271 -0
- package/src/svgrid.auto-row-height.test.ts +204 -0
- package/src/svgrid.charting.test.ts +4 -4
- package/src/svgrid.comments-autocomplete.test.ts +8 -2
- package/src/svgrid.context-menu.test.ts +25 -9
- package/src/svgrid.filter-depth.test.ts +163 -0
- package/src/svgrid.filter-menu-listbox.svelte.test.ts +16 -2
- package/src/svgrid.filter-menu-scroll.test.ts +10 -2
- package/src/svgrid.grand-total.test.ts +188 -0
- package/src/svgrid.group-display-mode.test.ts +171 -0
- package/src/svgrid.group-footers.test.ts +121 -0
- package/src/svgrid.group-pagination.test.ts +153 -0
- package/src/svgrid.tree-data.test.ts +186 -0
- package/src/svgriddropdown.async-panel.svelte.test.ts +195 -0
- package/src/themes/index.ts +42 -0
- package/src/tree-edit.test.ts +4 -4
- package/src/tree-row-model.test.ts +169 -0
- package/src/ui-localization.test.ts +113 -0
- package/themes/ag-alpine.css +22 -0
- package/themes/antd.css +22 -0
- package/themes/atlassian.css +22 -0
- package/themes/bootstrap.css +22 -0
- package/themes/carbon.css +22 -0
- package/themes/catppuccin.css +22 -0
- package/themes/dracula.css +22 -0
- package/themes/ember.css +22 -0
- package/themes/excel.css +22 -0
- package/themes/fluent.css +22 -0
- package/themes/github.css +22 -0
- package/themes/linear.css +22 -0
- package/themes/material.css +22 -0
- package/themes/nord.css +22 -0
- package/themes/notion.css +22 -0
- package/themes/salesforce.css +22 -0
- package/themes/sap.css +22 -0
- package/themes/shadcn.css +22 -0
- package/themes/tailwind.css +22 -0
- package/themes/vercel.css +22 -0
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: with grouping on, `pageSize` budgets DATA rows, not group banners (#73).
|
|
3
|
+
*
|
|
4
|
+
* The row model hands the view one flat list with group banners interleaved
|
|
5
|
+
* between their children. Slicing that list by `pageSize` spent the budget on
|
|
6
|
+
* banners: ten groups of two at `pageSize: 10` showed roughly three data rows,
|
|
7
|
+
* and with enough small groups a page could show almost none.
|
|
8
|
+
*
|
|
9
|
+
* Now the page window is measured in data rows, and each page reprints the
|
|
10
|
+
* banners its rows sit under - so a group split across a page boundary is
|
|
11
|
+
* labelled on both pages.
|
|
12
|
+
*/
|
|
13
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
14
|
+
import { mount, unmount } from 'svelte'
|
|
15
|
+
import SvGrid from './SvGrid.svelte'
|
|
16
|
+
import {
|
|
17
|
+
createCoreRowModel,
|
|
18
|
+
createExpandedRowModel,
|
|
19
|
+
createFilteredRowModel,
|
|
20
|
+
createGroupedRowModel,
|
|
21
|
+
createPaginatedRowModel,
|
|
22
|
+
createSortedRowModel,
|
|
23
|
+
sortFns,
|
|
24
|
+
tableFeatures,
|
|
25
|
+
rowSortingFeature,
|
|
26
|
+
columnGroupingFeature,
|
|
27
|
+
} from './index'
|
|
28
|
+
|
|
29
|
+
type Row = { id: number; region: string; amount: number }
|
|
30
|
+
|
|
31
|
+
// Ten groups of two rows - the shape from the report.
|
|
32
|
+
const data: Row[] = []
|
|
33
|
+
for (let g = 0; g < 10; g += 1) {
|
|
34
|
+
data.push({ id: g * 2, region: `R${g}`, amount: 1 })
|
|
35
|
+
data.push({ id: g * 2 + 1, region: `R${g}`, amount: 2 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const columns = [
|
|
39
|
+
{ field: 'region', header: 'Region', width: 120 },
|
|
40
|
+
{ field: 'amount', header: 'Amount', width: 100 },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const features = tableFeatures({ rowSortingFeature, columnGroupingFeature })
|
|
44
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
45
|
+
|
|
46
|
+
function mountGrid(pageSize: number, groupFooters = false) {
|
|
47
|
+
const target = document.createElement('div')
|
|
48
|
+
document.body.appendChild(target)
|
|
49
|
+
// Grouping state starts empty and is driven through the api - there is no
|
|
50
|
+
// initial-grouping prop - so group by region as soon as the api lands.
|
|
51
|
+
let api: { setGroupBy: (ids: string[]) => void } | null = null
|
|
52
|
+
const app = mount(SvGrid, {
|
|
53
|
+
target,
|
|
54
|
+
props: {
|
|
55
|
+
data,
|
|
56
|
+
columns,
|
|
57
|
+
features,
|
|
58
|
+
groupable: true,
|
|
59
|
+
groupFooters,
|
|
60
|
+
onApiReady: (a: never) => { api = a as never },
|
|
61
|
+
pageable: true,
|
|
62
|
+
pageSize,
|
|
63
|
+
_rowModels: {
|
|
64
|
+
coreRowModel: createCoreRowModel(),
|
|
65
|
+
filteredRowModel: createFilteredRowModel(),
|
|
66
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
67
|
+
groupedRowModel: createGroupedRowModel(),
|
|
68
|
+
expandedRowModel: createExpandedRowModel(),
|
|
69
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
70
|
+
},
|
|
71
|
+
containerHeight: 900,
|
|
72
|
+
virtualization: false,
|
|
73
|
+
} as never,
|
|
74
|
+
})
|
|
75
|
+
return {
|
|
76
|
+
target,
|
|
77
|
+
groupByRegion: () => api?.setGroupBy(['region']),
|
|
78
|
+
/** Groups render collapsed by default; click every banner to open them. */
|
|
79
|
+
expandAll: () => {
|
|
80
|
+
for (const btn of target.querySelectorAll<HTMLButtonElement>('.sv-grid-group-toggle')) {
|
|
81
|
+
if (btn.getAttribute('aria-expanded') !== 'true') btn.click()
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
destroy: () => { unmount(app); target.remove() },
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
let cleanup: (() => void) | null = null
|
|
89
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
90
|
+
|
|
91
|
+
const dataRows = (t: HTMLElement) =>
|
|
92
|
+
[...t.querySelectorAll('tbody tr.sv-grid-row')].filter(
|
|
93
|
+
(r) => !r.classList.contains('sv-grid-group-row') && !r.classList.contains('sv-grid-row-spacer'),
|
|
94
|
+
)
|
|
95
|
+
const groupRows = (t: HTMLElement) => [...t.querySelectorAll('tbody tr.sv-grid-group-row')]
|
|
96
|
+
|
|
97
|
+
async function grouped(pageSize: number, expand = true, groupFooters = false) {
|
|
98
|
+
const g = mountGrid(pageSize, groupFooters)
|
|
99
|
+
cleanup = g.destroy
|
|
100
|
+
await tick()
|
|
101
|
+
g.groupByRegion()
|
|
102
|
+
await tick()
|
|
103
|
+
if (expand) {
|
|
104
|
+
g.expandAll()
|
|
105
|
+
await tick()
|
|
106
|
+
}
|
|
107
|
+
return g
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
describe('grouped pagination (#73)', () => {
|
|
111
|
+
it('fills the page with pageSize DATA rows, not banners', async () => {
|
|
112
|
+
const { target } = await grouped(10)
|
|
113
|
+
// Before the fix the flat slice returned 10 TOTAL rows - banners included -
|
|
114
|
+
// so only a handful of them were data.
|
|
115
|
+
expect(dataRows(target)).toHaveLength(10)
|
|
116
|
+
// Those 10 data rows span 5 groups, so 5 banners come along for free.
|
|
117
|
+
expect(groupRows(target)).toHaveLength(5)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it('reports the data-row count in the pager, not banners + data', async () => {
|
|
121
|
+
const { target } = await grouped(10)
|
|
122
|
+
// 20 data rows at pageSize 10 = 2 pages. Counting banners would say 30.
|
|
123
|
+
expect(target.textContent).toMatch(/of\s+20\b/)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('reprints the banner for a group straddling a page boundary', async () => {
|
|
127
|
+
const { target } = await grouped(3)
|
|
128
|
+
// Page 0 holds R0's two rows plus R1's first, so both banners show.
|
|
129
|
+
const labels = groupRows(target).map((r) => r.textContent ?? '')
|
|
130
|
+
expect(labels.some((t) => t.includes('R0'))).toBe(true)
|
|
131
|
+
expect(labels.some((t) => t.includes('R1'))).toBe(true)
|
|
132
|
+
expect(dataRows(target)).toHaveLength(3)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('does not spend page budget on group footers', async () => {
|
|
136
|
+
// Footers are inserted after paging, so turning them on must not push data
|
|
137
|
+
// rows onto the next page.
|
|
138
|
+
const { target } = await grouped(10, true, true)
|
|
139
|
+
expect(dataRows(target).filter((r) => !r.classList.contains('sv-grid-group-footer-row')))
|
|
140
|
+
.toHaveLength(10)
|
|
141
|
+
expect(target.textContent).toMatch(/of\s+20\b/)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('still fills a page when every group is collapsed', async () => {
|
|
145
|
+
// Collapsed groups are the visible unit. Treating them as free headers
|
|
146
|
+
// emptied the page entirely - caught here, not by the pure helper.
|
|
147
|
+
const { target } = await grouped(4, false)
|
|
148
|
+
expect(groupRows(target)).toHaveLength(4)
|
|
149
|
+
expect(dataRows(target)).toHaveLength(0)
|
|
150
|
+
// 10 collapsed groups over pageSize 4.
|
|
151
|
+
expect(target.textContent).toMatch(/of\s+10\b/)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DOM: `treeData` renders a client-side hierarchy (#26).
|
|
3
|
+
*
|
|
4
|
+
* The point of the design is that tree rows stay REAL data rows: they render
|
|
5
|
+
* their own cells, so editing/formatting/selection all keep working, and only
|
|
6
|
+
* gain an expander plus indentation in the tree column. Grouping's banners are
|
|
7
|
+
* synthetic full-width rows - a tree row must never be rendered like one.
|
|
8
|
+
*/
|
|
9
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
10
|
+
import { mount, unmount } from 'svelte'
|
|
11
|
+
import SvGrid from './SvGrid.svelte'
|
|
12
|
+
import {
|
|
13
|
+
createCoreRowModel,
|
|
14
|
+
createFilteredRowModel,
|
|
15
|
+
createPaginatedRowModel,
|
|
16
|
+
createSortedRowModel,
|
|
17
|
+
flattenTreeData,
|
|
18
|
+
sortFns,
|
|
19
|
+
tableFeatures,
|
|
20
|
+
rowSortingFeature,
|
|
21
|
+
} from './index'
|
|
22
|
+
|
|
23
|
+
type Node = { id: number; managerId: number | null; name: string; team: string }
|
|
24
|
+
|
|
25
|
+
const data: Node[] = [
|
|
26
|
+
{ id: 1, managerId: null, name: 'Ada', team: 'Exec' },
|
|
27
|
+
{ id: 2, managerId: 1, name: 'Grace', team: 'Eng' },
|
|
28
|
+
{ id: 3, managerId: 2, name: 'Alan', team: 'Eng' },
|
|
29
|
+
{ id: 4, managerId: 1, name: 'Linus', team: 'Ops' },
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
const columns = [
|
|
33
|
+
{ field: 'name', header: 'Name', width: 200 },
|
|
34
|
+
{ field: 'team', header: 'Team', width: 120 },
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
const features = tableFeatures({ rowSortingFeature })
|
|
38
|
+
const tick = () => new Promise<void>((r) => queueMicrotask(r))
|
|
39
|
+
|
|
40
|
+
function mountGrid(extra: Record<string, unknown> = {}) {
|
|
41
|
+
const target = document.createElement('div')
|
|
42
|
+
document.body.appendChild(target)
|
|
43
|
+
const app = mount(SvGrid, {
|
|
44
|
+
target,
|
|
45
|
+
props: {
|
|
46
|
+
data,
|
|
47
|
+
columns,
|
|
48
|
+
features,
|
|
49
|
+
treeData: { parentField: 'managerId', column: 'name' },
|
|
50
|
+
_rowModels: {
|
|
51
|
+
coreRowModel: createCoreRowModel(),
|
|
52
|
+
filteredRowModel: createFilteredRowModel(),
|
|
53
|
+
sortedRowModel: createSortedRowModel(sortFns),
|
|
54
|
+
paginatedRowModel: createPaginatedRowModel(),
|
|
55
|
+
},
|
|
56
|
+
containerHeight: 600,
|
|
57
|
+
virtualization: false,
|
|
58
|
+
...extra,
|
|
59
|
+
} as never,
|
|
60
|
+
})
|
|
61
|
+
return { target, destroy: () => { unmount(app); target.remove() } }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
let cleanup: (() => void) | null = null
|
|
65
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
66
|
+
|
|
67
|
+
const bodyRows = (t: HTMLElement) =>
|
|
68
|
+
[...t.querySelectorAll('tbody tr.sv-grid-row')].filter(
|
|
69
|
+
(r) => !r.classList.contains('sv-grid-row-spacer'),
|
|
70
|
+
)
|
|
71
|
+
const toggles = (t: HTMLElement) =>
|
|
72
|
+
[...t.querySelectorAll<HTMLButtonElement>('.sv-grid-tree-toggle')]
|
|
73
|
+
|
|
74
|
+
describe('treeData', () => {
|
|
75
|
+
it('shows only roots until expanded', async () => {
|
|
76
|
+
const { target, destroy } = mountGrid()
|
|
77
|
+
cleanup = destroy
|
|
78
|
+
await tick()
|
|
79
|
+
expect(bodyRows(target)).toHaveLength(1)
|
|
80
|
+
expect(target.textContent).toContain('Ada')
|
|
81
|
+
expect(target.textContent).not.toContain('Grace')
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('expands to reveal children', async () => {
|
|
85
|
+
const { target, destroy } = mountGrid()
|
|
86
|
+
cleanup = destroy
|
|
87
|
+
await tick()
|
|
88
|
+
toggles(target)[0]!.click()
|
|
89
|
+
await tick()
|
|
90
|
+
expect(target.textContent).toContain('Grace')
|
|
91
|
+
expect(target.textContent).toContain('Linus')
|
|
92
|
+
// Alan is a grandchild - still hidden.
|
|
93
|
+
expect(target.textContent).not.toContain('Alan')
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('renders tree rows as normal cell rows, not full-width banners', async () => {
|
|
97
|
+
const { target, destroy } = mountGrid()
|
|
98
|
+
cleanup = destroy
|
|
99
|
+
await tick()
|
|
100
|
+
expect(target.querySelectorAll('tr.sv-grid-group-row')).toHaveLength(0)
|
|
101
|
+
// The root has its own cells - both columns render.
|
|
102
|
+
const cells = bodyRows(target)[0]!.querySelectorAll('.sv-grid-cell')
|
|
103
|
+
expect(cells.length).toBeGreaterThanOrEqual(2)
|
|
104
|
+
expect(bodyRows(target)[0]!.textContent).toContain('Exec')
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('uses the treegrid role with aria-level and aria-expanded', async () => {
|
|
108
|
+
const { target, destroy } = mountGrid()
|
|
109
|
+
cleanup = destroy
|
|
110
|
+
await tick()
|
|
111
|
+
expect(target.querySelector('[role="treegrid"]')).not.toBeNull()
|
|
112
|
+
const root = bodyRows(target)[0]!
|
|
113
|
+
expect(root.getAttribute('aria-level')).toBe('1')
|
|
114
|
+
expect(root.getAttribute('aria-expanded')).toBe('false')
|
|
115
|
+
|
|
116
|
+
toggles(target)[0]!.click()
|
|
117
|
+
await tick()
|
|
118
|
+
expect(bodyRows(target)[0]!.getAttribute('aria-expanded')).toBe('true')
|
|
119
|
+
// A child sits one level deeper.
|
|
120
|
+
expect(bodyRows(target)[1]!.getAttribute('aria-level')).toBe('2')
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('indents from ONE source only', async () => {
|
|
124
|
+
// `sv-grid-group-child-indent` exists for rows nested under a group banner
|
|
125
|
+
// and keys off `row.depth`, which tree rows also have. It used to stack on
|
|
126
|
+
// top of the tree indent, putting a 20px gap between a row's chevron and
|
|
127
|
+
// its text - the tree affordance owns indentation here.
|
|
128
|
+
const { target, destroy } = mountGrid()
|
|
129
|
+
cleanup = destroy
|
|
130
|
+
await tick()
|
|
131
|
+
toggles(target)[0]!.click()
|
|
132
|
+
await tick()
|
|
133
|
+
|
|
134
|
+
expect(target.querySelectorAll('.sv-grid-group-child-indent')).toHaveLength(0)
|
|
135
|
+
// Every name cell still carries exactly one tree indent.
|
|
136
|
+
const nameCells = [...target.querySelectorAll('[data-col-id="name"]')]
|
|
137
|
+
for (const cell of nameCells) {
|
|
138
|
+
expect(cell.querySelectorAll('.sv-grid-tree-indent')).toHaveLength(1)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('indents by depth x indentPx, honouring the override', async () => {
|
|
143
|
+
const { target, destroy } = mountGrid({
|
|
144
|
+
treeData: { parentField: 'managerId', column: 'name', indentPx: 30 },
|
|
145
|
+
})
|
|
146
|
+
cleanup = destroy
|
|
147
|
+
await tick()
|
|
148
|
+
toggles(target)[0]!.click()
|
|
149
|
+
await tick()
|
|
150
|
+
const widths = [...target.querySelectorAll<HTMLElement>('[data-col-id="name"] .sv-grid-tree-indent')]
|
|
151
|
+
.map((e) => e.style.width)
|
|
152
|
+
// Root at 0, its children one level in.
|
|
153
|
+
expect(widths[0]).toBe('0px')
|
|
154
|
+
expect(widths[1]).toBe('30px')
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('gives a leaf no expander but keeps it indented', async () => {
|
|
158
|
+
const { target, destroy } = mountGrid()
|
|
159
|
+
cleanup = destroy
|
|
160
|
+
await tick()
|
|
161
|
+
toggles(target)[0]!.click()
|
|
162
|
+
await tick()
|
|
163
|
+
// Linus is a leaf: spacer, no toggle button.
|
|
164
|
+
const linus = bodyRows(target).find((r) => r.textContent?.includes('Linus'))!
|
|
165
|
+
expect(linus.querySelector('.sv-grid-tree-toggle')).toBeNull()
|
|
166
|
+
expect(linus.querySelector('.sv-grid-tree-spacer')).not.toBeNull()
|
|
167
|
+
expect(linus.querySelector('.sv-grid-tree-indent')).not.toBeNull()
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('accepts nested data once flattened', async () => {
|
|
171
|
+
const nested = [
|
|
172
|
+
{ id: 1, name: 'Ada', team: 'Exec', reports: [{ id: 2, name: 'Grace', team: 'Eng' }] },
|
|
173
|
+
]
|
|
174
|
+
const flat = flattenTreeData(nested as never, { childrenField: 'reports' })
|
|
175
|
+
const { target, destroy } = mountGrid({
|
|
176
|
+
data: flat,
|
|
177
|
+
treeData: { parentField: '__parentId', column: 'name' },
|
|
178
|
+
})
|
|
179
|
+
cleanup = destroy
|
|
180
|
+
await tick()
|
|
181
|
+
expect(bodyRows(target)).toHaveLength(1)
|
|
182
|
+
toggles(target)[0]!.click()
|
|
183
|
+
await tick()
|
|
184
|
+
expect(target.textContent).toContain('Grace')
|
|
185
|
+
})
|
|
186
|
+
})
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dropdown panel's geometry is derived from the option COUNT, and it used
|
|
3
|
+
* to be measured only when the panel opened.
|
|
4
|
+
*
|
|
5
|
+
* With an async `editorOptions` the list is empty at that moment, so the panel
|
|
6
|
+
* was anchored and sized for ZERO options - about 8px tall. The options then
|
|
7
|
+
* arrived and rendered outside that box, unstyled, with the grid rows visible
|
|
8
|
+
* behind them. Reported from demo 428 as a broken editor.
|
|
9
|
+
*
|
|
10
|
+
* jsdom has no layout, so these assert the contract that produces the right
|
|
11
|
+
* geometry: the panel re-measures the trigger whenever the visible option list
|
|
12
|
+
* changes while it is open.
|
|
13
|
+
*/
|
|
14
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
15
|
+
import { mount, unmount } from 'svelte'
|
|
16
|
+
import SvGridDropdown from './SvGridDropdown.svelte'
|
|
17
|
+
|
|
18
|
+
const tick = () => new Promise<void>((r) => setTimeout(r, 0))
|
|
19
|
+
const settle = async () => { for (let i = 0; i < 4; i += 1) await tick() }
|
|
20
|
+
|
|
21
|
+
let cleanup: (() => void) | null = null
|
|
22
|
+
afterEach(() => { cleanup?.(); cleanup = null })
|
|
23
|
+
|
|
24
|
+
type Opt = { value: string; label: string }
|
|
25
|
+
const opts = (...labels: string[]): Opt[] => labels.map((l) => ({ value: l, label: l }))
|
|
26
|
+
|
|
27
|
+
/** Count trigger measurements, which is how the panel is anchored + sized. */
|
|
28
|
+
function trackRectReads(rect?: Partial<DOMRect>) {
|
|
29
|
+
const reads: number[] = []
|
|
30
|
+
const orig = HTMLElement.prototype.getBoundingClientRect
|
|
31
|
+
HTMLElement.prototype.getBoundingClientRect = function () {
|
|
32
|
+
reads.push(1)
|
|
33
|
+
return {
|
|
34
|
+
top: 100, left: 0, width: 160, height: 30,
|
|
35
|
+
bottom: 130, right: 160, x: 0, y: 100, toJSON() {},
|
|
36
|
+
...rect,
|
|
37
|
+
} as DOMRect
|
|
38
|
+
}
|
|
39
|
+
return { reads, restore: () => { HTMLElement.prototype.getBoundingClientRect = orig } }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const panelEl = () => document.querySelector('.sv-grid-dropdown-panel') as HTMLElement
|
|
43
|
+
|
|
44
|
+
const optionEls = () => document.querySelectorAll('.sv-grid-dropdown-option')
|
|
45
|
+
const emptyEl = () => document.querySelector('.sv-grid-dropdown-empty')
|
|
46
|
+
|
|
47
|
+
describe('dropdown panel with a late-arriving option list', () => {
|
|
48
|
+
it('re-measures the trigger when options land after opening', async () => {
|
|
49
|
+
const t = trackRectReads()
|
|
50
|
+
try {
|
|
51
|
+
const target = document.createElement('div')
|
|
52
|
+
document.body.appendChild(target)
|
|
53
|
+
// Opens with an empty list, exactly like an async source.
|
|
54
|
+
const props = $state({ options: [] as Opt[], value: null, onChange: () => {} })
|
|
55
|
+
const app = mount(SvGridDropdown, { target, props: props as never })
|
|
56
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
57
|
+
await settle()
|
|
58
|
+
|
|
59
|
+
expect(optionEls()).toHaveLength(0)
|
|
60
|
+
const afterOpen = t.reads.length
|
|
61
|
+
expect(afterOpen).toBeGreaterThan(0)
|
|
62
|
+
|
|
63
|
+
// The request resolves.
|
|
64
|
+
props.options = opts('Paris', 'Lyon', 'Marseille', 'Toulouse', 'Bordeaux')
|
|
65
|
+
await settle()
|
|
66
|
+
|
|
67
|
+
expect(optionEls()).toHaveLength(5)
|
|
68
|
+
// Sized for an empty list and never re-measured is the bug; the panel has
|
|
69
|
+
// to consult the trigger again once it has real content.
|
|
70
|
+
expect(t.reads.length).toBeGreaterThan(afterOpen)
|
|
71
|
+
} finally {
|
|
72
|
+
t.restore()
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('re-measures when a search filter shrinks the list', async () => {
|
|
77
|
+
const t = trackRectReads()
|
|
78
|
+
try {
|
|
79
|
+
const target = document.createElement('div')
|
|
80
|
+
document.body.appendChild(target)
|
|
81
|
+
const props = $state({
|
|
82
|
+
options: opts('Paris', 'Lyon', 'Marseille'),
|
|
83
|
+
value: null, searchable: true, onChange: () => {},
|
|
84
|
+
})
|
|
85
|
+
const app = mount(SvGridDropdown, { target, props: props as never })
|
|
86
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
87
|
+
await settle()
|
|
88
|
+
const before = t.reads.length
|
|
89
|
+
|
|
90
|
+
const search = document.querySelector('.sv-grid-dropdown-search') as HTMLInputElement
|
|
91
|
+
search.value = 'lyo'
|
|
92
|
+
search.dispatchEvent(new Event('input', { bubbles: true }))
|
|
93
|
+
await settle()
|
|
94
|
+
|
|
95
|
+
expect(optionEls()).toHaveLength(1)
|
|
96
|
+
expect(t.reads.length).toBeGreaterThan(before)
|
|
97
|
+
} finally {
|
|
98
|
+
t.restore()
|
|
99
|
+
}
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('shows Loading rather than "No options" while a list is in flight', async () => {
|
|
103
|
+
const target = document.createElement('div')
|
|
104
|
+
document.body.appendChild(target)
|
|
105
|
+
const app = mount(SvGridDropdown, {
|
|
106
|
+
target,
|
|
107
|
+
props: { options: [], value: null, loading: true, onChange: () => {} } as never,
|
|
108
|
+
})
|
|
109
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
110
|
+
await settle()
|
|
111
|
+
expect(emptyEl()?.textContent).toContain('Loading')
|
|
112
|
+
// Announced, so a screen reader hears the list arrive.
|
|
113
|
+
expect(emptyEl()?.getAttribute('role')).toBe('status')
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
it('still says "No options" when the list is genuinely empty', async () => {
|
|
117
|
+
const target = document.createElement('div')
|
|
118
|
+
document.body.appendChild(target)
|
|
119
|
+
const app = mount(SvGridDropdown, {
|
|
120
|
+
target,
|
|
121
|
+
props: { options: [], value: null, loading: false, onChange: () => {} } as never,
|
|
122
|
+
})
|
|
123
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
124
|
+
await settle()
|
|
125
|
+
expect(emptyEl()?.textContent).toContain('No options')
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
it('is not capped by the row-height estimate when the list fits', async () => {
|
|
129
|
+
// `anchoredRect` clamps its `maxHeight` to our estimate, which assumes a
|
|
130
|
+
// 32px row. Themes with a bigger font render taller rows, so capping the
|
|
131
|
+
// panel at that estimate cut off the last option. A short list must be
|
|
132
|
+
// limited only by the real room available.
|
|
133
|
+
const t = trackRectReads({ top: 100, bottom: 130 })
|
|
134
|
+
try {
|
|
135
|
+
const target = document.createElement('div')
|
|
136
|
+
document.body.appendChild(target)
|
|
137
|
+
const app = mount(SvGridDropdown, {
|
|
138
|
+
target,
|
|
139
|
+
props: { options: opts('FR', 'JP', 'US'), value: null, onChange: () => {} } as never,
|
|
140
|
+
})
|
|
141
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
142
|
+
await settle()
|
|
143
|
+
|
|
144
|
+
const max = parseFloat(panelEl().style.maxHeight)
|
|
145
|
+
// The 3-row estimate is 3*32 + 8 = 104. Anything at or under that would
|
|
146
|
+
// clip; the real space below the trigger is far larger.
|
|
147
|
+
expect(max).toBeGreaterThan(104)
|
|
148
|
+
} finally {
|
|
149
|
+
t.restore()
|
|
150
|
+
}
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('anchors an upward panel by its bottom edge', async () => {
|
|
154
|
+
// Upward panels used to be positioned by `top`, derived from the same
|
|
155
|
+
// estimate. Now that the panel may exceed the estimate, a top-anchored one
|
|
156
|
+
// would grow DOWN over the trigger; bottom-anchoring makes it grow up.
|
|
157
|
+
const t = trackRectReads({ top: 4000, bottom: 4030 })
|
|
158
|
+
try {
|
|
159
|
+
const target = document.createElement('div')
|
|
160
|
+
document.body.appendChild(target)
|
|
161
|
+
const app = mount(SvGridDropdown, {
|
|
162
|
+
target,
|
|
163
|
+
props: {
|
|
164
|
+
options: opts(...Array.from({ length: 12 }, (_, i) => `Opt ${i}`)),
|
|
165
|
+
value: null, onChange: () => {},
|
|
166
|
+
} as never,
|
|
167
|
+
})
|
|
168
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
169
|
+
await settle()
|
|
170
|
+
|
|
171
|
+
const el = panelEl()
|
|
172
|
+
expect(el.style.bottom).not.toBe('')
|
|
173
|
+
expect(el.style.top).toBe('')
|
|
174
|
+
} finally {
|
|
175
|
+
t.restore()
|
|
176
|
+
}
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it('swaps Loading for the options once they resolve', async () => {
|
|
180
|
+
const target = document.createElement('div')
|
|
181
|
+
document.body.appendChild(target)
|
|
182
|
+
const props = $state({ options: [] as Opt[], value: null, loading: true, onChange: () => {} })
|
|
183
|
+
const app = mount(SvGridDropdown, { target, props: props as never })
|
|
184
|
+
cleanup = () => { unmount(app); target.remove() }
|
|
185
|
+
await settle()
|
|
186
|
+
expect(emptyEl()?.textContent).toContain('Loading')
|
|
187
|
+
|
|
188
|
+
props.options = opts('Paris', 'Lyon')
|
|
189
|
+
props.loading = false
|
|
190
|
+
await settle()
|
|
191
|
+
|
|
192
|
+
expect(emptyEl()).toBeNull()
|
|
193
|
+
expect(optionEls()).toHaveLength(2)
|
|
194
|
+
})
|
|
195
|
+
})
|
package/src/themes/index.ts
CHANGED
|
@@ -35,6 +35,28 @@ export type ThemePalette = {
|
|
|
35
35
|
pinnedBorder?: string
|
|
36
36
|
/** Drop shadow cast by a pinned column into the scroll area. */
|
|
37
37
|
pinnedShadow?: string
|
|
38
|
+
/* Semantic status colors. The UI kit reads these for destructive buttons,
|
|
39
|
+
validation errors, alert variants, badges and the rating star. A preset
|
|
40
|
+
that omits them gets the mode-appropriate defaults below, which match the
|
|
41
|
+
inline fallbacks the components shipped with - so adding these changed
|
|
42
|
+
nothing for existing presets, it only made them themeable. */
|
|
43
|
+
/** Errors, destructive actions, invalid cells. */
|
|
44
|
+
danger?: string
|
|
45
|
+
/** Confirmations, positive deltas. */
|
|
46
|
+
success?: string
|
|
47
|
+
/** Cautions, pending states, the rating star. */
|
|
48
|
+
warning?: string
|
|
49
|
+
/** Neutral informational callouts. */
|
|
50
|
+
info?: string
|
|
51
|
+
/** Focus outline color. Defaults to the preset's accent. */
|
|
52
|
+
focusRing?: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Status-color defaults per mode - dark mode lifts them so they stay legible
|
|
56
|
+
* on a dark ground. Values match the components' own inline fallbacks. */
|
|
57
|
+
const STATUS_DEFAULTS: Record<ThemeMode, { danger: string; success: string; warning: string; info: string }> = {
|
|
58
|
+
light: { danger: '#dc2626', success: '#16a34a', warning: '#d97706', info: '#0891b2' },
|
|
59
|
+
dark: { danger: '#f87171', success: '#4ade80', warning: '#fbbf24', info: '#22d3ee' },
|
|
38
60
|
}
|
|
39
61
|
|
|
40
62
|
export type ThemePreset = {
|
|
@@ -156,6 +178,7 @@ export function resolveThemeTokens(preset: ThemePreset | undefined, mode: ThemeM
|
|
|
156
178
|
const resolved = preset ?? defaultThemePreset
|
|
157
179
|
const p = resolved[mode]
|
|
158
180
|
const accent = accentOverride || p.accent
|
|
181
|
+
const status = STATUS_DEFAULTS[mode]
|
|
159
182
|
return {
|
|
160
183
|
'--sg-bg': p.bg,
|
|
161
184
|
'--sg-fg': p.fg,
|
|
@@ -194,6 +217,25 @@ export function resolveThemeTokens(preset: ThemePreset | undefined, mode: ThemeM
|
|
|
194
217
|
'--sg-on-accent': onAccent(accent),
|
|
195
218
|
'--sg-radius': `${resolved.radius}px`,
|
|
196
219
|
'--sg-font': resolved.font,
|
|
220
|
+
// Semantic status colors. Same reasoning as the scrollbar tokens below:
|
|
221
|
+
// the UI kit consumes these (destructive buttons, alerts, validation,
|
|
222
|
+
// badges, the rating star), so a preset that doesn't define them leaves
|
|
223
|
+
// every status color at a hard-coded red/green/amber that ignores the
|
|
224
|
+
// theme and, on dark presets, sits at the wrong lightness.
|
|
225
|
+
'--sg-danger': p.danger ?? status.danger,
|
|
226
|
+
'--sg-success': p.success ?? status.success,
|
|
227
|
+
'--sg-warning': p.warning ?? status.warning,
|
|
228
|
+
'--sg-info': p.info ?? status.info,
|
|
229
|
+
// A COLOR, not a shadow - consumed as `outline: 2px solid var(...)`.
|
|
230
|
+
'--sg-focus-ring': p.focusRing ?? accent,
|
|
231
|
+
// Derived surfaces + shape the kit reads. Deriving from the palette keeps
|
|
232
|
+
// them following the preset and the light/dark mode.
|
|
233
|
+
'--sg-muted-bg': p.rowHover,
|
|
234
|
+
'--sg-skeleton-bg': p.rowHover,
|
|
235
|
+
'--sg-radius-lg': `${resolved.radius * 2}px`,
|
|
236
|
+
'--sg-rating-on': p.warning ?? status.warning,
|
|
237
|
+
'--sg-rating-empty': p.border,
|
|
238
|
+
'--sg-invalid-fg': p.danger ?? status.danger,
|
|
197
239
|
// The grid's custom scrollbar is a shadow-DOM web component that reads its
|
|
198
240
|
// OWN --sg-scrollbar-* tokens (with fixed light-mode fallbacks). Without
|
|
199
241
|
// these it ignores the theme entirely - a light scrollbar left on a dark
|
package/src/tree-edit.test.ts
CHANGED
|
@@ -20,7 +20,7 @@ describe('moveTreeNode', () => {
|
|
|
20
20
|
it('moves inside a folder (appends to children)', () => {
|
|
21
21
|
const out = moveTreeNode(tree, 'b', 'a', 'inside')
|
|
22
22
|
expect(ids(out)).toEqual(['a', 'c'])
|
|
23
|
-
expect(out[0]
|
|
23
|
+
expect(out[0]!.children!.map((n) => n.id)).toEqual(['a1', 'a2', 'b'])
|
|
24
24
|
})
|
|
25
25
|
it('refuses to drop a node into its own descendant, or onto itself', () => {
|
|
26
26
|
expect(moveTreeNode(tree, 'a', 'a1', 'inside')).toEqual(tree)
|
|
@@ -37,13 +37,13 @@ describe('sortTreeNodes', () => {
|
|
|
37
37
|
]
|
|
38
38
|
const asc = sortTreeNodes(unsorted, (x, y) => x.label.localeCompare(y.label))
|
|
39
39
|
expect(ids(asc)).toEqual(['a', 'm', 'z'])
|
|
40
|
-
expect(asc[1]
|
|
40
|
+
expect(asc[1]!.children!.map((n) => n.label)).toEqual(['Apple', 'Pear'])
|
|
41
41
|
})
|
|
42
42
|
})
|
|
43
43
|
|
|
44
44
|
describe('treeContains', () => {
|
|
45
45
|
it('detects descendants', () => {
|
|
46
|
-
expect(treeContains(tree[0]
|
|
47
|
-
expect(treeContains(tree[0]
|
|
46
|
+
expect(treeContains(tree[0]!, 'a2')).toBe(true)
|
|
47
|
+
expect(treeContains(tree[0]!, 'b')).toBe(false)
|
|
48
48
|
})
|
|
49
49
|
})
|