@operato/data-grist 10.29.6 → 10.29.8
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 +18 -0
- package/dist/src/data-grid/data-grid-body-style.js +2 -10
- package/dist/src/data-grid/data-grid-body-style.js.map +1 -1
- package/dist/src/data-grid/data-grid-body.d.ts +1 -0
- package/dist/src/data-grid/data-grid-body.js +35 -12
- package/dist/src/data-grid/data-grid-body.js.map +1 -1
- package/dist/src/data-grid/data-grid-header.js +5 -10
- package/dist/src/data-grid/data-grid-header.js.map +1 -1
- package/dist/src/data-grid/data-grid.d.ts +2 -1
- package/dist/src/data-grid/data-grid.js +74 -21
- package/dist/src/data-grid/data-grid.js.map +1 -1
- package/dist/test/accessible-name.test.js +6 -4
- package/dist/test/accessible-name.test.js.map +1 -1
- package/dist/test/field-contains-content.test.js +8 -5
- package/dist/test/field-contains-content.test.js.map +1 -1
- package/dist/test/first-click-stays.test.d.ts +1 -0
- package/dist/test/first-click-stays.test.js +77 -0
- package/dist/test/first-click-stays.test.js.map +1 -0
- package/dist/test/grid-single-scroller.test.d.ts +1 -0
- package/dist/test/grid-single-scroller.test.js +107 -0
- package/dist/test/grid-single-scroller.test.js.map +1 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/test/accessible-name.test.ts +6 -4
- package/test/field-contains-content.test.ts +10 -5
- package/test/first-click-stays.test.ts +101 -0
- package/test/grid-single-scroller.test.ts +150 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@operato/data-grist",
|
|
3
|
-
"version": "10.29.
|
|
3
|
+
"version": "10.29.8",
|
|
4
4
|
"description": "User interface for grid (desktop) and list (mobile)",
|
|
5
5
|
"author": "heartyoh",
|
|
6
6
|
"type": "module",
|
|
@@ -116,5 +116,5 @@
|
|
|
116
116
|
"prettier --write"
|
|
117
117
|
]
|
|
118
118
|
},
|
|
119
|
-
"gitHead": "
|
|
119
|
+
"gitHead": "7afbba99cf2119ccb709b6cb0cff8b58d447ef88"
|
|
120
120
|
}
|
|
@@ -348,8 +348,9 @@ describe('grouped header spans two rows', () => {
|
|
|
348
348
|
* wrapper. Two things then depend on this structure:
|
|
349
349
|
*
|
|
350
350
|
* ```
|
|
351
|
-
* column layout the host is a flex column.
|
|
352
|
-
*
|
|
351
|
+
* column layout the host is a flex column. The wrapper is itself a flex column (it is the one
|
|
352
|
+
* scroller that header and body share), so the body still takes the height
|
|
353
|
+
* left under the header
|
|
353
354
|
* sibling selector "show the settings button on header hover" is an adjacent-sibling rule, so
|
|
354
355
|
* the button has to sit directly after the wrapper
|
|
355
356
|
* ```
|
|
@@ -378,7 +379,7 @@ describe('grid wrapper stays out of the layout', () => {
|
|
|
378
379
|
expect(grid.nextElementSibling, 'adjacent-sibling rule no longer holds').to.equal(setting)
|
|
379
380
|
})
|
|
380
381
|
|
|
381
|
-
it('
|
|
382
|
+
it('lays header and body out as a column that fills the host', async () => {
|
|
382
383
|
const el: any = await fixture(html`
|
|
383
384
|
<div style="height: 300px; display: flex">
|
|
384
385
|
<ox-grid
|
|
@@ -396,7 +397,8 @@ describe('grid wrapper stays out of the layout', () => {
|
|
|
396
397
|
const header = grid.renderRoot.querySelector('ox-grid-header') as HTMLElement
|
|
397
398
|
const body = grid.renderRoot.querySelector('ox-grid-body') as HTMLElement
|
|
398
399
|
|
|
399
|
-
expect(getComputedStyle(wrapper).display).to.equal('
|
|
400
|
+
expect(getComputedStyle(wrapper).display).to.equal('flex')
|
|
401
|
+
expect(getComputedStyle(wrapper).flexDirection).to.equal('column')
|
|
400
402
|
|
|
401
403
|
/* The body takes the remaining height; a wrapper with a box shrinks it to header height. */
|
|
402
404
|
expect(body.offsetHeight, 'body does not take the remaining height').to.be.greaterThan(100)
|
|
@@ -11,7 +11,8 @@ import '../src/data-grist.js'
|
|
|
11
11
|
* `span` and `pre` were clipped, so a long string — or an `<a>` — spilled past its track. The body's
|
|
12
12
|
* scrollWidth grew past the header's (the header is just the sum of the column widths): at the far
|
|
13
13
|
* right the header stopped while the body kept scrolling, and stray text showed where there were no
|
|
14
|
-
* columns.
|
|
14
|
+
* columns. Header and body now share one scroller, so the overflow shows as the grid scrolling past
|
|
15
|
+
* its last column instead.
|
|
15
16
|
*
|
|
16
17
|
* [measured] operato-office contract list — body 5503 / header 3830 (2026-10-04)
|
|
17
18
|
*
|
|
@@ -55,6 +56,7 @@ async function grid(renderer: (value: unknown) => unknown) {
|
|
|
55
56
|
await el.updateComplete
|
|
56
57
|
|
|
57
58
|
const grist = el.renderRoot.querySelector('#grist') as Root
|
|
59
|
+
const scroller = grist.renderRoot.querySelector('div[grid]') as HTMLElement
|
|
58
60
|
const header = grist.renderRoot.querySelector('ox-grid-header') as Root
|
|
59
61
|
const body = grist.renderRoot.querySelector('ox-grid-body') as Root
|
|
60
62
|
await body.updateComplete
|
|
@@ -63,7 +65,7 @@ async function grid(renderer: (value: unknown) => unknown) {
|
|
|
63
65
|
f => (f as any).column?.name === 'note'
|
|
64
66
|
) as Root[]
|
|
65
67
|
|
|
66
|
-
return { header, body, notes }
|
|
68
|
+
return { scroller, header, body, notes }
|
|
67
69
|
}
|
|
68
70
|
|
|
69
71
|
describe('a grid cell keeps its content inside its column', () => {
|
|
@@ -92,11 +94,14 @@ describe('a grid cell keeps its content inside its column', () => {
|
|
|
92
94
|
]
|
|
93
95
|
|
|
94
96
|
for (const [kind, renderer] of renderers) {
|
|
95
|
-
it(`${kind}: the
|
|
96
|
-
const { header, body, notes } = await grid(renderer)
|
|
97
|
+
it(`${kind}: the grid scrolls exactly as far as its columns reach`, async () => {
|
|
98
|
+
const { scroller, header, body, notes } = await grid(renderer)
|
|
97
99
|
|
|
98
100
|
expect(notes.length, 'the long cells must be drawn, or this measures nothing').to.be.at.least(2)
|
|
99
|
-
expect(body.
|
|
101
|
+
expect(body.offsetWidth, 'header and body span the same width').to.equal(header.offsetWidth)
|
|
102
|
+
expect(scroller.scrollWidth, 'content past the last column scrolls on into empty space').to.equal(
|
|
103
|
+
header.offsetWidth
|
|
104
|
+
)
|
|
100
105
|
})
|
|
101
106
|
|
|
102
107
|
it(`${kind}: the cell is as wide as its column and clips what does not fit`, async () => {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/* Elements are compared by presence, not by value: a failing element comparison makes chai render the DOM and hang the runner. */
|
|
2
|
+
import { expect, fixture } from '@open-wc/testing'
|
|
3
|
+
import { resetMouse, sendMouse } from '@web/test-runner-commands'
|
|
4
|
+
import { html } from 'lit'
|
|
5
|
+
|
|
6
|
+
import '../src/data-grist.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The first cell you click stays where you clicked it.
|
|
10
|
+
*
|
|
11
|
+
* Clicking a cell sets the new focus and calls focus() at once, before the body has drawn that
|
|
12
|
+
* focus. The input buffer that takes the focus was still where it had never been placed (top left),
|
|
13
|
+
* and the browser scrolled the grid to show it. Then the grid scrolled the focused cell back into
|
|
14
|
+
* view — at the far edge. A cell clicked in the middle of a grid pushed 1000px to the right ended up
|
|
15
|
+
* at the right edge (0, then 581). From the second click on the buffer sat near the last cell, so it
|
|
16
|
+
* only showed the first time.
|
|
17
|
+
*
|
|
18
|
+
* The grid scrolls its own focus into view, so focusing the buffer must not scroll.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
type Root = HTMLElement & { renderRoot: ShadowRoot; updateComplete: Promise<unknown> }
|
|
22
|
+
|
|
23
|
+
const settle = (ms = 150) => new Promise(resolve => setTimeout(resolve, ms))
|
|
24
|
+
|
|
25
|
+
describe('the first click on a scrolled grid', () => {
|
|
26
|
+
const ignoreResizeLoop = (e: ErrorEvent) => {
|
|
27
|
+
if (String(e.message).includes('ResizeObserver loop')) {
|
|
28
|
+
e.stopImmediatePropagation()
|
|
29
|
+
e.preventDefault()
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
let previousOnError: OnErrorEventHandler = null
|
|
33
|
+
before(() => {
|
|
34
|
+
window.addEventListener('error', ignoreResizeLoop, true)
|
|
35
|
+
previousOnError = window.onerror
|
|
36
|
+
window.onerror = (message, ...rest) =>
|
|
37
|
+
String(message).includes('ResizeObserver loop') ? true : previousOnError?.call(window, message, ...rest)
|
|
38
|
+
})
|
|
39
|
+
after(async function () {
|
|
40
|
+
/* resetMouse is a round trip to the browser driver; under a full parallel run it takes seconds. */
|
|
41
|
+
this.timeout(10000)
|
|
42
|
+
/* Let the resize that the last layout queued arrive while the filter is still in place. */
|
|
43
|
+
await settle(300)
|
|
44
|
+
window.removeEventListener('error', ignoreResizeLoop, true)
|
|
45
|
+
window.onerror = previousOnError
|
|
46
|
+
await resetMouse()
|
|
47
|
+
})
|
|
48
|
+
|
|
49
|
+
it('does not move the grid when the clicked cell is already in view', async function () {
|
|
50
|
+
this.timeout(10000)
|
|
51
|
+
const columns: any[] = [
|
|
52
|
+
{ type: 'gutter', gutterName: 'sequence' },
|
|
53
|
+
{ type: 'string', name: 'id', hidden: true },
|
|
54
|
+
{ type: 'string', name: 'code', header: 'code', width: 140, record: { editable: true } }
|
|
55
|
+
]
|
|
56
|
+
for (let i = 0; i < 30; i++) {
|
|
57
|
+
columns.push({ type: 'string', name: 'c' + i, header: 'col ' + i, width: 120, record: { editable: true } })
|
|
58
|
+
}
|
|
59
|
+
const records = Array.from({ length: 30 }, (_, r) => {
|
|
60
|
+
const record: any = { id: String(r), code: 'C-' + r }
|
|
61
|
+
for (let i = 0; i < 30; i++) record['c' + i] = `v${r}-${i}`
|
|
62
|
+
return record
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const el = (await fixture(html`
|
|
66
|
+
<ox-grist
|
|
67
|
+
style="display: flex; width: 700px; height: 360px"
|
|
68
|
+
mode="GRID"
|
|
69
|
+
.config=${{ columns, pagination: { pages: [100] } }}
|
|
70
|
+
.fetchHandler=${async () => ({ total: records.length, records })}
|
|
71
|
+
></ox-grist>
|
|
72
|
+
`)) as Root & { fetch(): Promise<void> }
|
|
73
|
+
await el.fetch()
|
|
74
|
+
await settle()
|
|
75
|
+
|
|
76
|
+
const grist = el.renderRoot.querySelector('#grist') as Root
|
|
77
|
+
const scroller = grist.renderRoot.querySelector('div[grid]') as HTMLElement
|
|
78
|
+
const body = grist.renderRoot.querySelector('ox-grid-body') as Root
|
|
79
|
+
await body.updateComplete
|
|
80
|
+
|
|
81
|
+
scroller.scrollLeft = 1000
|
|
82
|
+
await settle(50)
|
|
83
|
+
expect(scroller.scrollLeft, 'the grid must scroll, or this measures nothing').to.equal(1000)
|
|
84
|
+
|
|
85
|
+
/* A cell in the middle of the view, in the third row. */
|
|
86
|
+
const row = body.renderRoot.querySelectorAll('div[grid-row]')[2] as HTMLElement
|
|
87
|
+
const view = scroller.getBoundingClientRect()
|
|
88
|
+
const cell = (Array.from(row.querySelectorAll('ox-grid-field')) as HTMLElement[]).find(f => {
|
|
89
|
+
const r = f.getBoundingClientRect()
|
|
90
|
+
return r.left > view.left + 200 && r.right < view.right - 200
|
|
91
|
+
})!
|
|
92
|
+
const before = cell.getBoundingClientRect()
|
|
93
|
+
|
|
94
|
+
await sendMouse({ type: 'click', position: [Math.round(before.left + 20), Math.round(before.top + before.height / 2)] })
|
|
95
|
+
await settle()
|
|
96
|
+
|
|
97
|
+
expect(cell.hasAttribute('focused'), 'the clicked cell takes the focus').to.equal(true)
|
|
98
|
+
expect(scroller.scrollLeft, 'the grid moved under the pointer').to.equal(1000)
|
|
99
|
+
expect(Math.round(cell.getBoundingClientRect().left)).to.equal(Math.round(before.left))
|
|
100
|
+
})
|
|
101
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/* Elements are compared by presence, not by value: a failing element comparison makes chai render the DOM and hang the runner. */
|
|
2
|
+
import { expect, fixture } from '@open-wc/testing'
|
|
3
|
+
import { html } from 'lit'
|
|
4
|
+
|
|
5
|
+
import '../src/data-grist.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Header and body move as one.
|
|
9
|
+
*
|
|
10
|
+
* The body used to be its own scroller, and the header a second scroller that copied the body's
|
|
11
|
+
* scrollLeft from its scroll event (and the other way round). Three things went wrong:
|
|
12
|
+
*
|
|
13
|
+
* ```
|
|
14
|
+
* gap the compositor moves the body at once; the header only moves after the main thread
|
|
15
|
+
* handles the event, so on a busy screen it trails by several frames
|
|
16
|
+
* shake the sync ran both ways, so a late header event put the body back one frame
|
|
17
|
+
* the end when the two scroll widths differ, the header stops while the body goes on
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* Now the role=grid box is the only scroller. The header sticks to its top and the body does not
|
|
21
|
+
* scroll. Nothing copies a position, so nothing can trail or pull back.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
type Root = HTMLElement & { renderRoot: ShadowRoot; updateComplete: Promise<unknown> }
|
|
25
|
+
|
|
26
|
+
const settle = (ms = 150) => new Promise(resolve => setTimeout(resolve, ms))
|
|
27
|
+
|
|
28
|
+
async function grid() {
|
|
29
|
+
const columns: any[] = [
|
|
30
|
+
{ type: 'gutter', gutterName: 'sequence' },
|
|
31
|
+
{ type: 'string', name: 'id', hidden: true },
|
|
32
|
+
{ type: 'string', name: 'code', header: 'code', width: 120, fixed: true }
|
|
33
|
+
]
|
|
34
|
+
for (let i = 0; i < 12; i++) {
|
|
35
|
+
columns.push({ type: 'string', name: 'c' + i, header: 'col ' + i, width: 110 })
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const records = Array.from({ length: 60 }, (_, r) => {
|
|
39
|
+
const record: any = { id: String(r), code: 'C-' + r }
|
|
40
|
+
for (let i = 0; i < 12; i++) record['c' + i] = `v${r}-${i}`
|
|
41
|
+
return record
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const el = (await fixture(html`
|
|
45
|
+
<ox-grist
|
|
46
|
+
style="width: 600px; height: 320px"
|
|
47
|
+
mode="GRID"
|
|
48
|
+
.config=${{ columns, pagination: { pages: [100] } }}
|
|
49
|
+
.fetchHandler=${async () => ({ total: records.length, records })}
|
|
50
|
+
></ox-grist>
|
|
51
|
+
`)) as Root & { fetch(): Promise<void> }
|
|
52
|
+
|
|
53
|
+
await el.fetch()
|
|
54
|
+
await settle()
|
|
55
|
+
await el.updateComplete
|
|
56
|
+
|
|
57
|
+
const grist = el.renderRoot.querySelector('#grist') as Root & { focused?: { row: number; column: number } }
|
|
58
|
+
const scroller = grist.renderRoot.querySelector('div[grid]') as HTMLElement
|
|
59
|
+
const header = grist.renderRoot.querySelector('ox-grid-header') as Root
|
|
60
|
+
const body = grist.renderRoot.querySelector('ox-grid-body') as Root
|
|
61
|
+
await body.updateComplete
|
|
62
|
+
|
|
63
|
+
return { grist, scroller, header, body }
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The largest horizontal distance between a column's header cell and its cell in the first row. */
|
|
67
|
+
function misalignment(header: Root, body: Root) {
|
|
68
|
+
const heads = Array.from(header.renderRoot.querySelectorAll('[role=columnheader]')) as HTMLElement[]
|
|
69
|
+
const row = body.renderRoot.querySelector('div[grid-row]') as HTMLElement
|
|
70
|
+
const cells = Array.from(row.querySelectorAll('ox-grid-field')) as HTMLElement[]
|
|
71
|
+
const n = Math.min(heads.length, cells.length)
|
|
72
|
+
|
|
73
|
+
let worst = 0
|
|
74
|
+
for (let i = 0; i < n; i++) {
|
|
75
|
+
worst = Math.max(worst, Math.abs(heads[i].getBoundingClientRect().left - cells[i].getBoundingClientRect().left))
|
|
76
|
+
}
|
|
77
|
+
return { n, worst }
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
describe('grid header and body share one scroller', () => {
|
|
81
|
+
/* Resizing the grid body inside a fixture trips Chrome's benign "ResizeObserver loop" error. */
|
|
82
|
+
const ignoreResizeLoop = (e: ErrorEvent) => {
|
|
83
|
+
if (String(e.message).includes('ResizeObserver loop')) {
|
|
84
|
+
e.stopImmediatePropagation()
|
|
85
|
+
e.preventDefault()
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
let previousOnError: OnErrorEventHandler = null
|
|
89
|
+
before(() => {
|
|
90
|
+
window.addEventListener('error', ignoreResizeLoop, true)
|
|
91
|
+
previousOnError = window.onerror
|
|
92
|
+
window.onerror = (message, ...rest) =>
|
|
93
|
+
String(message).includes('ResizeObserver loop') ? true : previousOnError?.call(window, message, ...rest)
|
|
94
|
+
})
|
|
95
|
+
after(() => {
|
|
96
|
+
window.removeEventListener('error', ignoreResizeLoop, true)
|
|
97
|
+
window.onerror = previousOnError
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('neither the header nor the body scrolls on its own', async () => {
|
|
101
|
+
const { scroller, header, body } = await grid()
|
|
102
|
+
|
|
103
|
+
expect(scroller.scrollWidth, 'the grid must overflow, or this measures nothing').to.be.greaterThan(
|
|
104
|
+
scroller.clientWidth
|
|
105
|
+
)
|
|
106
|
+
expect(getComputedStyle(body).overflowX).to.equal('visible')
|
|
107
|
+
expect(getComputedStyle(header).overflowX).to.equal('visible')
|
|
108
|
+
expect(header.offsetWidth, 'header and body span the same width').to.equal(body.offsetWidth)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
for (const where of ['start', 'middle', 'end'] as const) {
|
|
112
|
+
it(`every column lines up with its header at the ${where}`, async () => {
|
|
113
|
+
const { scroller, header, body } = await grid()
|
|
114
|
+
|
|
115
|
+
const max = scroller.scrollWidth - scroller.clientWidth
|
|
116
|
+
scroller.scrollLeft = where === 'start' ? 0 : where === 'middle' ? Math.round(max / 2) : max
|
|
117
|
+
await settle(50)
|
|
118
|
+
|
|
119
|
+
const { n, worst } = misalignment(header, body)
|
|
120
|
+
expect(n, 'columns must be drawn, or this measures nothing').to.be.greaterThan(10)
|
|
121
|
+
expect(worst).to.equal(0)
|
|
122
|
+
})
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
it('the header stays on top when the rows scroll', async () => {
|
|
126
|
+
const { scroller, header } = await grid()
|
|
127
|
+
|
|
128
|
+
scroller.scrollTop = 600
|
|
129
|
+
await settle(50)
|
|
130
|
+
|
|
131
|
+
expect(scroller.scrollTop, 'the rows must scroll, or this measures nothing').to.be.greaterThan(0)
|
|
132
|
+
expect(header.getBoundingClientRect().top).to.equal(scroller.getBoundingClientRect().top)
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('a focused row that was under the header is brought out below it', async () => {
|
|
136
|
+
const { grist, scroller, header, body } = await grid()
|
|
137
|
+
|
|
138
|
+
scroller.scrollTop = 600
|
|
139
|
+
await settle(50)
|
|
140
|
+
|
|
141
|
+
grist.focused = { row: 2, column: 3 }
|
|
142
|
+
await grist.updateComplete
|
|
143
|
+
await body.updateComplete
|
|
144
|
+
await settle(50)
|
|
145
|
+
|
|
146
|
+
const focused = body.renderRoot.querySelector('ox-grid-field[focused]') as HTMLElement
|
|
147
|
+
expect(!!focused, 'a cell must take the focus').to.equal(true)
|
|
148
|
+
expect(focused.getBoundingClientRect().top).to.be.at.least(header.getBoundingClientRect().bottom - 1)
|
|
149
|
+
})
|
|
150
|
+
})
|