@svgrid/grid 2.2.31 → 2.2.33
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/DockNodeView.svelte +1 -1
- package/dist/GridFooter.svelte +0 -40
- package/dist/GridMenus.svelte +0 -77
- package/dist/SvAvatar.svelte +1 -1
- package/dist/SvCheckBox.svelte +2 -1
- package/dist/SvCircularProgress.svelte +1 -1
- package/dist/SvDateRangeInput.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 +164 -35
- package/dist/SvGrid.types.d.ts +81 -0
- package/dist/SvGridChart.svelte +2 -10
- package/dist/SvGridDropdown.svelte +38 -6
- package/dist/SvGridDropdown.svelte.d.ts +4 -0
- package/dist/SvGridSelect.svelte +12 -5
- package/dist/SvGridSelect.svelte.d.ts +6 -0
- package/dist/SvGroupCell.svelte +9 -2
- package/dist/SvKbd.svelte +1 -1
- package/dist/SvList.svelte +1 -1
- package/dist/SvMenubar.svelte +1 -1
- package/dist/SvMultiSelect.svelte +12 -4
- package/dist/SvMultiSelect.svelte.d.ts +6 -0
- package/dist/SvOtpInput.svelte +1 -1
- 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 +35 -16
- package/dist/SvRichText.svelte.d.ts +9 -0
- package/dist/SvSwitchButton.svelte +2 -1
- package/dist/SvToaster.svelte +1 -1
- package/dist/SvTour.svelte +4 -0
- package/dist/SvTree.svelte +9 -2
- package/dist/SvTree.svelte.d.ts +5 -0
- package/dist/SvTreeSelect.svelte +6 -4
- package/dist/build-api.js +16 -0
- package/dist/cdn/{GridMenus-Ds3OpzZT.js → GridMenus-B7XhhqdN.js} +34 -36
- package/dist/cdn/{GridMenus-Da_p7SKS.js → GridMenus-BxmIvCJh.js} +35 -37
- package/dist/cdn/{SvDateTimePicker-DB0aIEk8.js → SvDateTimePicker-ClqtVIj3.js} +2 -1
- package/dist/cdn/{SvDateTimePicker-B8GopjhC.js → SvDateTimePicker-o94wfSUa.js} +3 -2
- package/dist/cdn/{SvGridChart-BcLCYvw9.js → SvGridChart-Cr_90pPD.js} +1 -1
- package/dist/cdn/{SvGridChartPanel-DvjwxLvK.js → SvGridChartPanel-ICbxiUvK.js} +4 -4
- package/dist/cdn/{SvGridChartView-PwqI4s02.js → SvGridChartView-Bx42PMvf.js} +2 -2
- package/dist/cdn/{SvGridDropdown-hpnHvOGt.js → SvGridDropdown-CUOdm7YA.js} +93 -88
- package/dist/cdn/{SvGridDropdown-DjALc_4f.js → SvGridDropdown-D0VdjeR8.js} +129 -123
- package/dist/cdn/{disclose-version-DoD9AFD_.js → disclose-version-DljhZohK.js} +45 -45
- package/dist/cdn/{src-DYDCiC0D.js → src-erjBRYfX.js} +5952 -5615
- package/dist/cdn/{src-BLJfdyL0.js → src-ktbQVOk-.js} +4190 -3851
- package/dist/cdn/svgrid.js +9 -9
- 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/chart.d.ts +2 -0
- package/dist/chart.js +1 -4
- package/dist/core.d.ts +80 -9
- package/dist/core.js +89 -0
- package/dist/createMenu.svelte.js +0 -1
- package/dist/createPopoverSelect.svelte.d.ts +2 -2
- package/dist/editing.js +1 -1
- 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/export-format.js +3 -0
- 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/selection.js +0 -1
- 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 +3 -2
- package/src/DockNodeView.svelte +1 -1
- package/src/GridFooter.svelte +0 -40
- package/src/GridMenus.svelte +0 -77
- package/src/SvAccordion.test.ts +5 -5
- package/src/SvAvatar.svelte +1 -1
- package/src/SvButtonGroup.test.ts +6 -6
- package/src/SvCalendar.test.ts +1 -1
- package/src/SvCheckBox.svelte +2 -1
- package/src/SvCircularProgress.svelte +1 -1
- package/src/SvDateRangeInput.svelte +1 -1
- package/src/SvFileUpload.test.ts +1 -1
- package/src/SvGrid.controller.svelte.ts +367 -22
- package/src/SvGrid.css +103 -3
- package/src/SvGrid.svelte +164 -35
- package/src/SvGrid.types.ts +81 -1
- package/src/SvGridChart.svelte +2 -10
- package/src/SvGridDropdown.svelte +38 -6
- package/src/SvGridSelect.svelte +12 -5
- package/src/SvGroupCell.svelte +9 -2
- package/src/SvKbd.svelte +1 -1
- package/src/SvList.svelte +1 -1
- package/src/SvMenubar.svelte +1 -1
- package/src/SvMultiSelect.svelte +12 -4
- package/src/SvOtpInput.svelte +1 -1
- 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 +35 -16
- package/src/SvSwitchButton.svelte +2 -1
- package/src/SvToaster.svelte +1 -1
- package/src/SvTour.svelte +4 -0
- package/src/SvTree.svelte +9 -2
- package/src/SvTreeSelect.svelte +6 -4
- package/src/ai.test.ts +1 -1
- package/src/build-api.ts +15 -105
- package/src/builtin-editors.test.ts +3 -1
- package/src/cell-formatting.test.ts +1 -1
- package/src/cell-render.test.ts +27 -3
- package/src/cell-render.ts +122 -95
- package/src/cell-values.ts +4 -0
- package/src/chart-export.test.ts +2 -2
- package/src/chart.ts +1 -2
- package/src/clipboard.test.ts +0 -2
- package/src/clipboard.ts +0 -102
- package/src/columns.ts +0 -115
- package/src/core.ts +157 -8
- package/src/createMenu.svelte.ts +0 -1
- package/src/editing.test.ts +1 -1
- package/src/editing.ts +2 -105
- package/src/editor-contract.ts +12 -5
- package/src/editor-registry.grid.test.ts +48 -2
- package/src/editor-registry.test.ts +3 -1
- package/src/editor-registry.ts +23 -7
- package/src/export-format.ts +3 -0
- package/src/facet-buckets.test.ts +33 -33
- package/src/features.ts +0 -90
- 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.coverage.test.ts +1 -1
- package/src/keyboard-handlers.ts +17 -120
- package/src/list-power.test.ts +2 -2
- package/src/menus.test.ts +4 -5
- package/src/menus.ts +0 -112
- package/src/named-views.coverage.test.ts +3 -3
- package/src/new-features.test.ts +3 -1
- package/src/recurrence.ts +3 -2
- package/src/row-drag.test.ts +1 -1
- package/src/row-resize.test.ts +41 -42
- package/src/scheduler-model.ts +1 -1
- package/src/scroll-sync.test.ts +4 -7
- package/src/scroll-sync.ts +0 -122
- package/src/selection.ts +0 -109
- package/src/server-data-source.coverage.test.ts +1 -1
- package/src/server-group-model.test.ts +8 -8
- package/src/spreadsheet.test.ts +0 -1
- package/src/summaries.ts +0 -112
- 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.behavior.test.ts +0 -1
- package/src/svgrid.charting.test.ts +4 -4
- package/src/svgrid.column-reorder.test.ts +0 -8
- 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 +168 -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,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
|
})
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { createTreeRowModel, flattenTreeData } from './core'
|
|
3
|
+
|
|
4
|
+
type Node = { id: number; parent: number | null; name: string }
|
|
5
|
+
|
|
6
|
+
/** Minimal Row stand-ins - the model only reads `original`, `id` and spreads. */
|
|
7
|
+
function rowsFrom(data: Node[]) {
|
|
8
|
+
return data.map((original, index) => ({
|
|
9
|
+
id: String(original.id),
|
|
10
|
+
index,
|
|
11
|
+
original,
|
|
12
|
+
depth: 0,
|
|
13
|
+
getCanExpand: () => false,
|
|
14
|
+
getIsExpanded: () => false,
|
|
15
|
+
toggleExpanded: () => {},
|
|
16
|
+
getIsSelected: () => false,
|
|
17
|
+
toggleSelected: () => {},
|
|
18
|
+
getAllCells: () => [],
|
|
19
|
+
getCellValueByColumnId: (c: string) => (original as never as Record<string, unknown>)[c],
|
|
20
|
+
})) as never[]
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
let expandedState: Record<string, boolean> = {}
|
|
24
|
+
const table = {
|
|
25
|
+
getState: () => ({ expanded: expandedState }),
|
|
26
|
+
setExpanded: (fn: (p: Record<string, boolean>) => Record<string, boolean>) => {
|
|
27
|
+
expandedState = fn(expandedState)
|
|
28
|
+
},
|
|
29
|
+
} as never
|
|
30
|
+
|
|
31
|
+
const run = (data: Node[], opts = { parentField: 'parent' }) =>
|
|
32
|
+
createTreeRowModel<never>(opts)({ table, rows: rowsFrom(data) } as never)
|
|
33
|
+
|
|
34
|
+
const ids = (rows: readonly { id: string }[]) => rows.map((r) => r.id)
|
|
35
|
+
|
|
36
|
+
describe('createTreeRowModel', () => {
|
|
37
|
+
const data: Node[] = [
|
|
38
|
+
{ id: 1, parent: null, name: 'root' },
|
|
39
|
+
{ id: 2, parent: 1, name: 'child a' },
|
|
40
|
+
{ id: 3, parent: 1, name: 'child b' },
|
|
41
|
+
{ id: 4, parent: 2, name: 'grandchild' },
|
|
42
|
+
{ id: 5, parent: null, name: 'other root' },
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
it('nests by parent id and returns only roots', () => {
|
|
46
|
+
const out = run(data)
|
|
47
|
+
expect(ids(out)).toEqual(['1', '5'])
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('records depth down the chain', () => {
|
|
51
|
+
const [root] = run(data) as never as Array<{ depth: number; subRows: never[] }>
|
|
52
|
+
expect(root!.depth).toBe(0)
|
|
53
|
+
const child = (root!.subRows as never as Array<{ id: string; depth: number; subRows: never[] }>)[0]!
|
|
54
|
+
expect(child.depth).toBe(1)
|
|
55
|
+
expect((child.subRows as never as Array<{ depth: number }>)[0]!.depth).toBe(2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('marks rows with children expandable and leaves not', () => {
|
|
59
|
+
const [root, other] = run(data) as never as Array<{ getCanExpand: () => boolean }>
|
|
60
|
+
expect(root!.getCanExpand()).toBe(true)
|
|
61
|
+
expect(other!.getCanExpand()).toBe(false)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('counts descendants, not just direct children', () => {
|
|
65
|
+
const [root] = run(data) as never as Array<{ leafCount: number }>
|
|
66
|
+
// children a + b, plus a's grandchild
|
|
67
|
+
expect(root!.leafCount).toBe(3)
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('tags rows so they are not mistaken for group banners', async () => {
|
|
71
|
+
const { isGroupRow } = await import('./cell-values')
|
|
72
|
+
const [root] = run(data) as never as Array<never>
|
|
73
|
+
expect((root as never as { __treeRow: boolean }).__treeRow).toBe(true)
|
|
74
|
+
// Expandable, but must NOT render as a full-width banner.
|
|
75
|
+
expect(isGroupRow(root!)).toBe(false)
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
it('drives expansion through the table state', () => {
|
|
79
|
+
expandedState = {}
|
|
80
|
+
const [root] = run(data) as never as Array<{
|
|
81
|
+
getIsExpanded: () => boolean
|
|
82
|
+
toggleExpanded: () => void
|
|
83
|
+
}>
|
|
84
|
+
expect(root!.getIsExpanded()).toBe(false)
|
|
85
|
+
root!.toggleExpanded()
|
|
86
|
+
expect(expandedState['1']).toBe(true)
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
it('promotes a row whose parent is missing rather than dropping it', () => {
|
|
90
|
+
// Parent 99 does not exist - e.g. the filter removed it. The row must still
|
|
91
|
+
// be reachable.
|
|
92
|
+
const orphaned: Node[] = [
|
|
93
|
+
{ id: 1, parent: null, name: 'root' },
|
|
94
|
+
{ id: 2, parent: 99, name: 'orphan' },
|
|
95
|
+
]
|
|
96
|
+
expect(ids(run(orphaned))).toEqual(['1', '2'])
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
it('survives a parent cycle', () => {
|
|
100
|
+
const cyclic: Node[] = [
|
|
101
|
+
{ id: 1, parent: 2, name: 'a' },
|
|
102
|
+
{ id: 2, parent: 1, name: 'b' },
|
|
103
|
+
]
|
|
104
|
+
// Neither is a root by parent link, so nothing is emitted rather than
|
|
105
|
+
// recursing forever. The guard is what matters.
|
|
106
|
+
expect(() => run(cyclic)).not.toThrow()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('treats a self-parenting row as a root', () => {
|
|
110
|
+
expect(ids(run([{ id: 1, parent: 1, name: 'self' }]))).toEqual(['1'])
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('passes an empty set straight through', () => {
|
|
114
|
+
expect(run([])).toEqual([])
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('honours a custom idField', () => {
|
|
118
|
+
const keyed = [
|
|
119
|
+
{ key: 10, parent: null },
|
|
120
|
+
{ key: 11, parent: 10 },
|
|
121
|
+
] as never as Node[]
|
|
122
|
+
const out = createTreeRowModel<never>({ parentField: 'parent', idField: 'key' })({
|
|
123
|
+
table,
|
|
124
|
+
rows: rowsFrom(keyed),
|
|
125
|
+
} as never) as never as Array<{ subRows: never[] }>
|
|
126
|
+
expect(out).toHaveLength(1)
|
|
127
|
+
expect(out[0]!.subRows).toHaveLength(1)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
describe('flattenTreeData', () => {
|
|
132
|
+
const nested = [
|
|
133
|
+
{ id: 1, name: 'a', kids: [{ id: 2, name: 'a1', kids: [{ id: 3, name: 'a1a' }] }] },
|
|
134
|
+
{ id: 4, name: 'b' },
|
|
135
|
+
]
|
|
136
|
+
|
|
137
|
+
it('emits children directly after their parent', () => {
|
|
138
|
+
const flat = flattenTreeData(nested as never, { childrenField: 'kids' })
|
|
139
|
+
expect(flat.map((r) => (r as never as { id: number }).id)).toEqual([1, 2, 3, 4])
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('stamps the parent link the tree model reads', () => {
|
|
143
|
+
const flat = flattenTreeData(nested as never, { childrenField: 'kids' }) as never as Array<
|
|
144
|
+
Record<string, unknown>
|
|
145
|
+
>
|
|
146
|
+
expect(flat[0]!.__parentId).toBeNull()
|
|
147
|
+
expect(flat[1]!.__parentId).toBe(1)
|
|
148
|
+
expect(flat[2]!.__parentId).toBe(2)
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('round-trips into createTreeRowModel', () => {
|
|
152
|
+
const flat = flattenTreeData(nested as never, { childrenField: 'kids' })
|
|
153
|
+
const out = createTreeRowModel<never>({ parentField: '__parentId' })({
|
|
154
|
+
table,
|
|
155
|
+
rows: rowsFrom(flat as never as Node[]),
|
|
156
|
+
} as never) as never as Array<{ id: string; subRows: never[] }>
|
|
157
|
+
expect(out.map((r) => r.id)).toEqual(['1', '4'])
|
|
158
|
+
expect(out[0]!.subRows).toHaveLength(1)
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
it('accepts a custom parent field name', () => {
|
|
162
|
+
const flat = flattenTreeData(nested as never, {
|
|
163
|
+
childrenField: 'kids',
|
|
164
|
+
parentField: 'pid',
|
|
165
|
+
}) as never as Array<Record<string, unknown>>
|
|
166
|
+
expect(flat[1]!.pid).toBe(1)
|
|
167
|
+
})
|
|
168
|
+
})
|