@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operato/data-grist",
3
- "version": "10.29.6",
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": "4ce15fe4cf60d6bb6810ec86f5eb9839298001a2"
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. A wrapper that generates a box swallows header
352
- * and body into one flex item, and the body's flex: 1 stops working
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('leaves header and body as flex items of the host', async () => {
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('contents')
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 body scrolls exactly as far as the header`, async () => {
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.scrollWidth, 'content past the last column widens only the body').to.equal(header.scrollWidth)
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
+ })