@operato/data-grist 10.29.5 → 10.29.7

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/coverage/lcov-report/index.html +40 -40
  3. package/coverage/lcov-report/webpack/bootstrap.html +1 -1
  4. package/coverage/lcov-report/webpack/index.html +1 -1
  5. package/coverage/lcov-report/webpack/runtime/define property getters.html +1 -1
  6. package/coverage/lcov-report/webpack/runtime/global.html +1 -1
  7. package/coverage/lcov-report/webpack/runtime/hasOwnProperty shorthand.html +1 -1
  8. package/coverage/lcov-report/webpack/runtime/index.html +1 -1
  9. package/coverage/lcov-report/webpack/runtime/make namespace object.html +1 -1
  10. package/coverage/lcov.info +30482 -30377
  11. package/dist/src/data-card/data-card-field.js +3 -0
  12. package/dist/src/data-card/data-card-field.js.map +1 -1
  13. package/dist/src/data-grid/data-grid-accum-field.js +4 -0
  14. package/dist/src/data-grid/data-grid-accum-field.js.map +1 -1
  15. package/dist/src/data-grid/data-grid-body-style.js +2 -10
  16. package/dist/src/data-grid/data-grid-body-style.js.map +1 -1
  17. package/dist/src/data-grid/data-grid-body.d.ts +1 -0
  18. package/dist/src/data-grid/data-grid-body.js +20 -9
  19. package/dist/src/data-grid/data-grid-body.js.map +1 -1
  20. package/dist/src/data-grid/data-grid-field.d.ts +1 -0
  21. package/dist/src/data-grid/data-grid-field.js +29 -1
  22. package/dist/src/data-grid/data-grid-field.js.map +1 -1
  23. package/dist/src/data-grid/data-grid-header.js +5 -10
  24. package/dist/src/data-grid/data-grid-header.js.map +1 -1
  25. package/dist/src/data-grid/data-grid.d.ts +2 -1
  26. package/dist/src/data-grid/data-grid.js +72 -20
  27. package/dist/src/data-grid/data-grid.js.map +1 -1
  28. package/dist/test/accessible-name.test.js +6 -4
  29. package/dist/test/accessible-name.test.js.map +1 -1
  30. package/dist/test/field-contains-content.test.d.ts +1 -0
  31. package/dist/test/field-contains-content.test.js +101 -0
  32. package/dist/test/field-contains-content.test.js.map +1 -0
  33. package/dist/test/grid-single-scroller.test.d.ts +1 -0
  34. package/dist/test/grid-single-scroller.test.js +107 -0
  35. package/dist/test/grid-single-scroller.test.js.map +1 -0
  36. package/dist/tsconfig.tsbuildinfo +1 -1
  37. package/package.json +2 -2
  38. package/test/accessible-name.test.ts +6 -4
  39. package/test/field-contains-content.test.ts +125 -0
  40. 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.5",
3
+ "version": "10.29.7",
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": "0995f71d6a9678da116781ec38ae80a253ab8b3e"
119
+ "gitHead": "9e14db8a889f2b59cc0ad9018861b14cf9490ce1"
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)
@@ -0,0 +1,125 @@
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
+ * A cell keeps what its renderer draws inside its column.
9
+ *
10
+ * `FieldRenderer` may return a plain string, and the cell rendered it as a bare text node. Only
11
+ * `span` and `pre` were clipped, so a long string — or an `<a>` — spilled past its track. The body's
12
+ * scrollWidth grew past the header's (the header is just the sum of the column widths): at the far
13
+ * right the header stopped while the body kept scrolling, and stray text showed where there were no
14
+ * columns. Header and body now share one scroller, so the overflow shows as the grid scrolling past
15
+ * its last column instead.
16
+ *
17
+ * [measured] operato-office contract list — body 5503 / header 3830 (2026-10-04)
18
+ *
19
+ * The long cells sit in the last column, because only content that runs past the last track widens
20
+ * the body; in a middle column the next cell paints over it and the defect hides.
21
+ */
22
+
23
+ const LONG = '긴 글자가 열 너비를 넘는다 '.repeat(40)
24
+
25
+ const settle = (ms = 150) => new Promise(resolve => setTimeout(resolve, ms))
26
+
27
+ type Root = HTMLElement & { renderRoot: ShadowRoot; updateComplete: Promise<unknown> }
28
+
29
+ async function grid(renderer: (value: unknown) => unknown) {
30
+ const config = {
31
+ columns: [
32
+ { type: 'string', name: 'id', hidden: true },
33
+ { type: 'string', name: 'name', header: 'name', width: 120 },
34
+ { type: 'string', name: 'note', header: 'note', width: 160, record: { renderer } }
35
+ ],
36
+ pagination: { pages: [20] }
37
+ }
38
+
39
+ const el = (await fixture(html`
40
+ <ox-grist
41
+ style="width: 400px; height: 300px"
42
+ mode="GRID"
43
+ .config=${config}
44
+ .fetchHandler=${async () => ({
45
+ total: 2,
46
+ records: [
47
+ { id: '1', name: 'a', note: LONG },
48
+ { id: '2', name: 'b', note: LONG }
49
+ ]
50
+ })}
51
+ ></ox-grist>
52
+ `)) as Root & { fetch(): Promise<void> }
53
+
54
+ await el.fetch()
55
+ await settle()
56
+ await el.updateComplete
57
+
58
+ const grist = el.renderRoot.querySelector('#grist') as Root
59
+ const scroller = grist.renderRoot.querySelector('div[grid]') as HTMLElement
60
+ const header = grist.renderRoot.querySelector('ox-grid-header') as Root
61
+ const body = grist.renderRoot.querySelector('ox-grid-body') as Root
62
+ await body.updateComplete
63
+
64
+ const notes = Array.from(body.renderRoot.querySelectorAll('ox-grid-field')).filter(
65
+ f => (f as any).column?.name === 'note'
66
+ ) as Root[]
67
+
68
+ return { scroller, header, body, notes }
69
+ }
70
+
71
+ describe('a grid cell keeps its content inside its column', () => {
72
+ /* Resizing the grid body inside a fixture trips Chrome's benign "ResizeObserver loop" error. */
73
+ const ignoreResizeLoop = (e: ErrorEvent) => {
74
+ if (String(e.message).includes('ResizeObserver loop')) {
75
+ e.stopImmediatePropagation()
76
+ e.preventDefault()
77
+ }
78
+ }
79
+ let previousOnError: OnErrorEventHandler = null
80
+ before(() => {
81
+ window.addEventListener('error', ignoreResizeLoop, true)
82
+ previousOnError = window.onerror
83
+ window.onerror = (message, ...rest) =>
84
+ String(message).includes('ResizeObserver loop') ? true : previousOnError?.call(window, message, ...rest)
85
+ })
86
+ after(() => {
87
+ window.removeEventListener('error', ignoreResizeLoop, true)
88
+ window.onerror = previousOnError
89
+ })
90
+
91
+ const renderers: [string, (value: unknown) => unknown][] = [
92
+ ['a plain string', value => String(value)],
93
+ ['an <a>', value => html`<a href="#">${value}</a>`]
94
+ ]
95
+
96
+ for (const [kind, renderer] of renderers) {
97
+ it(`${kind}: the grid scrolls exactly as far as its columns reach`, async () => {
98
+ const { scroller, header, body, notes } = await grid(renderer)
99
+
100
+ expect(notes.length, 'the long cells must be drawn, or this measures nothing').to.be.at.least(2)
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
+ )
105
+ })
106
+
107
+ it(`${kind}: the cell is as wide as its column and clips what does not fit`, async () => {
108
+ const { notes } = await grid(renderer)
109
+
110
+ for (const note of notes) {
111
+ expect(Math.round(note.getBoundingClientRect().width)).to.equal(160)
112
+ expect(getComputedStyle(note).overflowX).to.equal('hidden')
113
+ }
114
+ })
115
+ }
116
+
117
+ it('a plain string still ends in an ellipsis', async () => {
118
+ const { notes } = await grid(value => String(value))
119
+
120
+ const text = notes[0].renderRoot.querySelector('span') as HTMLElement | null
121
+ expect(!!text, 'the string sits in a box that can take text-overflow').to.equal(true)
122
+ expect(getComputedStyle(text!).textOverflow).to.equal('ellipsis')
123
+ expect(text!.clientWidth).to.be.lessThan(160)
124
+ })
125
+ })
@@ -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
+ })