• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

tropy / tropy / 4345679489

pending completion
4345679489

push

github

Sylvester Keil
v1.13.0-beta.6

1220 of 6725 branches covered (18.14%)

Branch coverage included in aggregate %.

3638 of 12302 relevant lines covered (29.57%)

9.22 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

16.55
/src/components/scroll/scroll.js
1
import React from 'react'
2
import { array, bool, func, number, object, oneOf, string } from 'prop-types'
3
import { Range } from './range'
4
import { Runway } from './runway'
5
import { ScrollContainer } from './container'
6
import { getExpandedRows, getExpandedRowsAbove } from './expansion'
7
import { Viewport } from './viewport'
8
import { restrict } from '../../common/util'
9
import { indexOf, sanitize } from '../../common/collection'
10
import memoize from 'memoize-one'
11

12

13
export class Scroll extends React.Component {
14
  container = React.createRef()
1✔
15

16
  state = {
1✔
17
    isScrolling: false,
18

19
    // Derived from container size
20
    width: 0,
21
    height: 0,
22

23
    // Derived from scroll position
24
    offset: 0,
25
    row: 0,
26
    numRowsAbove: 0,
27
    expRowPosition: 0
28
  }
29

30
  componentDidMount() {
31
    this.handleResize(this.container.current.bounds)
1✔
32
  }
33

34
  componentWillUnmount() {
35
    cancelAnimationFrame(this.#scrollCallback.current)
1✔
36
  }
37

38
  componentDidUpdate({ cursor, itemHeight }) {
39
    if (cursor !== this.props.cursor ||
1!
40
      itemHeight !== this.props.itemHeight)
41
      this.scrollIntoView()
×
42
  }
43

44
  get tabIndex() {
45
    return this.props.items.length === 0 ? null : this.props.tabIndex
2!
46
  }
47

48
  get cursor() {
49
    let { cursor, items, expansionCursor, expandedItems } = this.props
×
50

51
    let index = indexOf(items, cursor)
×
52
    let expRowPosition = 0
×
53

54
    if (expansionCursor != null) {
×
55
      let expIndex = expandedItems[cursor]?.indexOf(expansionCursor)
×
56

57
      if (expIndex != null && expIndex >= 0)
×
58
        expRowPosition = expIndex + 1
×
59
    }
60

61
    return [index, expRowPosition]
×
62
  }
63

64
  get current() {
65
    return this.next(0)
×
66
  }
67

68
  next(k = 1) {
×
69
    let [cursor, expRowPosition] = this.cursor
×
70

71
    if (cursor === -1)
×
72
      return (k < 0) ? this.last() : this.first()
×
73

74
    let { expandedRows, isGrid } = this.layout
×
75
    let { numRowsAbove } =
76
      getExpandedRowsAbove(expandedRows, { index: cursor })
×
77

78
    let row = cursor + k
×
79
    let len = this.props.items.length
×
80

81
    if (!isGrid) {
×
82
      row += numRowsAbove + expRowPosition
×
83
      len += expandedRows.length
×
84
    }
85

86
    row = sanitize(len, row, this.props.restrict)
×
87

88
    if (row == null)
×
89
      return null
×
90

91
    if (isGrid)
×
92
      return this.props.items[row]
×
93

94
    let nxt = getExpandedRowsAbove(expandedRows, { position: row })
×
95
    let item = this.props.items[row - nxt.numRowsAbove]
×
96

97
    if (nxt.expRowPosition) {
×
98
      // TODO selection name!
99
      let selection = item.selections[nxt.expRowPosition - 1]
×
100
      return { ...item, selection }
×
101
    }
102

103
    return item
×
104
  }
105

106
  prev(k = 1) {
×
107
    return this.next(-k)
×
108
  }
109

110
  first() {
111
    return this.props.items[0]
×
112
  }
113

114
  last() {
115
    let { items, expandedItems } = this.props
×
116
    let { isGrid } = this.layout
×
117
    let item = items[items.length - 1]
×
118
    let expansion = expandedItems[item.id]
×
119

120
    // TODO selection name!
121
    if (!isGrid && expansion) {
×
122
      let selection = expansion[expansion.length - 1]
×
123
      return { ...item, selection }
×
124
    }
125

126
    return item
×
127
  }
128

129
  pageUp() {
130
    let { items, itemHeight } = this.props
×
131
    let { columns } = this.layout
×
132

133
    this.scrollPageUp()
×
134

135
    let offset = this.container.current.scrollTop
×
136
    let row = Math.floor(offset / itemHeight)
×
137

138
    // TODO handle list expansions
139
    // TODO account for expansion padding
140

141
    return items[row * columns]
×
142
  }
143

144
  pageDown() {
145
    let { items, itemHeight } = this.props
×
146
    let { columns } = this.layout
×
147

148
    this.scrollPageDown()
×
149

150
    let top = this.container.current.scrollTop
×
151
    let offset = top + this.state.height - itemHeight
×
152
    let row = Math.floor(offset / itemHeight)
×
153

154
    // TODO handle list expansions
155
    // TODO account for expansion padding
156

157
    return items[row * columns]
×
158
  }
159

160
  select(item, event) {
161
    this.props.onSelect?.(item, event)
×
162
    return item
×
163
  }
164

165
  focus() {
166
    this.container.current.focus()
×
167
  }
168

169
  getComputedLayout = memoize((
1✔
170
    items,
171
    itemHeight,
172
    itemWidth,
173
    height,
174
    width,
175
    expandedItems,
176
    expansionPadding,
177
    overscan
178
  ) => {
179
    let columns = Math.floor(width / itemWidth) || 1
2✔
180
    let isGrid = itemWidth > 0
2✔
181

182
    let expandedRows = getExpandedRows(
2✔
183
      columns,
184
      items,
185
      expandedItems,
186
      isGrid)
187

188
    let rows = Math.ceil(items.length / columns) + expandedRows.length
2✔
189
    let visibleRows = Math.ceil(height / itemHeight)
2✔
190
    let visibleItems = visibleRows * columns
2✔
191
    let overscanRows = Math.max(2, Math.ceil(visibleRows * overscan))
2✔
192
    let rowsPerPage = visibleRows + overscanRows
2✔
193

194
    let runway = rows * itemHeight
2✔
195

196
    if (expandedRows.length > 0)
2!
197
      runway += expansionPadding
×
198

199
    let pageOffset = Math.floor(overscanRows / 2) * itemHeight
2✔
200

201
    let maxOffset = runway - (rowsPerPage * itemHeight)
2✔
202
    maxOffset = Math.max(maxOffset - (maxOffset % itemHeight), 0)
2✔
203

204
    return {
2✔
205
      columns,
206
      expandedRows,
207
      isGrid,
208
      rows,
209
      rowsPerPage,
210
      runway,
211
      pageOffset,
212
      maxOffset,
213
      visibleItems
214
    }
215
  })
216

217
  handleKeyDown = (event) => {
1✔
218
    // By default, the home, end, page up, page down and arrow keys
219
    // will work as expected for the ScrollContainer. If we have an
220
    // `onSelect` callback, however, we override the default behavior
221
    // of the arrow keys to select items instead of only scrolling
222
    // the container.
223
    if (this.props.onSelect)
×
224
      this.handleArrowKeys(event)
×
225

226
    if (!event.isDefaultPrevented()) {
×
227
      this.props.onKeyDown?.(event)
×
228
    }
229
  }
230

231
  // eslint-disable-next-line complexity
232
  handleArrowKeys(event) {
233
    if (event.ctrlKey || event.metaKey)
×
234
      return
×
235

236
    let { items } = this.props
×
237
    let { columns, isGrid } = this.layout
×
238
    let [cursor] = this.cursor
×
239

240
    if (cursor === -1) ++cursor
×
241

242
    if (isGrid && this.props.restrict === 'bounds') {
×
243
      var isFirstRow = cursor < columns
×
244
      var isLastRow =
245
        cursor >= (items.length - ((items.length % columns) || columns))
×
246
    }
247

248
    switch (event.key) {
×
249
      case 'ArrowDown':
250
        if (event.altKey)
×
251
          this.select(this.last(), event)
×
252
        else {
253
          if (!isLastRow)
×
254
            this.select(this.next(columns), event)
×
255
        }
256
        break
×
257

258
      case 'ArrowUp':
259
        if (event.altKey)
×
260
          this.select(this.first(), event)
×
261
        else {
262
          if (!isFirstRow)
×
263
            this.select(this.prev(columns), event)
×
264
        }
265
        break
×
266

267
      case 'ArrowRight':
268
        if (!isGrid)
×
269
          return
×
270

271
        if (event.altKey)
×
272
          this.select(this.next(columns - 1 - (cursor % columns)), event)
×
273
        else
274
          this.select(this.next(), event)
×
275
        break
×
276

277
      case 'ArrowLeft':
278
        if (!isGrid)
×
279
          return
×
280

281
        if (event.altKey)
×
282
          this.select(this.prev(cursor % columns), event)
×
283
        else
284
          this.select(this.prev(), event)
×
285
        break
×
286

287
      default:
288
        return
×
289
    }
290

291
    event.preventDefault()
×
292
    event.stopPropagation()
×
293
    event.nativeEvent.stopImmediatePropagation()
×
294
  }
295

296
  handleResize = ({ width, height }) => {
1✔
297
    this.setState({ width, height }, () => {
1✔
298
      this.handleScroll()
1✔
299
    })
300
  }
301

302
  handleScrollStart = () => {
1✔
303
    this.setState({ isScrolling: true })
×
304
  }
305

306
  handleScrollStop = () => {
1✔
307
    this.setState({ isScrolling: false })
×
308
  }
309

310
  handleScroll = () => {
1✔
311
    if (!this.#scrollCallback.current) {
1!
312
      this.#scrollCallback.current =
1✔
313
        requestAnimationFrame(this.#scrollCallback)
314
    }
315
  }
316

317
  #scrollCallback = () => {
1✔
318
    this.#scrollCallback.current = null
×
319
    this.handleScrollUpdate(this.container.current.scrollTop)
×
320
  }
321

322
  handleScrollUpdate(top) {
323
    let { itemHeight, expansionPadding } = this.props
×
324
    let { expandedRows, maxOffset, pageOffset } = this.layout
×
325

326
    let offset = restrict(
×
327
      top - (top % itemHeight) - pageOffset,
328
      0,
329
      maxOffset)
330

331
    let row = Math.floor(offset / itemHeight)
×
332

333
    let { numRowsAbove, expRowPosition } =
334
        getExpandedRowsAbove(expandedRows, { position: row })
×
335

336
    if (expandedRows.length) {
×
337
      offset = (row - expRowPosition) * itemHeight
×
338

339
      if (numRowsAbove > 0 && expRowPosition === 0)
×
340
        offset += expansionPadding
×
341
    }
342

343
    this.setState({
×
344
      offset,
345
      row,
346
      numRowsAbove,
347
      expRowPosition
348
    })
349
  }
350

351
  handleTabFocus = (event) => {
1✔
352
    if (this.props.autoselect)
×
353
      this.select(this.current)
×
354
    else
355
      this.scrollIntoView()
×
356

357
    this.props.onTabFocus?.(event)
×
358
  }
359

360
  scroll(...args) {
361
    this.container.current.scroll(...args)
×
362
  }
363

364
  scrollBy(...args) {
365
    this.container.current.scrollBy(...args)
×
366
  }
367

368
  scrollPageDown() {
369
    this.scrollBy(this.state.height)
×
370
  }
371

372
  scrollPageUp() {
373
    this.scrollBy(-this.state.height)
×
374
  }
375

376
  scrollToEnd() {
377
    this.scroll(this.layout.maxOffset)
×
378
  }
379

380
  scrollIntoView(cursor = this.cursor, { force } = {}) {
×
381
    let expRowPosition = 0
×
382

383
    if (Array.isArray(cursor)) {
×
384
      expRowPosition = cursor[1]
×
385
      cursor = cursor[0]
×
386
    }
387

388
    if (cursor != null && typeof cursor === 'object')
×
389
      cursor = indexOf(this.props.items, cursor.id)
×
390

391
    if (cursor == null || cursor < 0)
×
392
      return
×
393

394
    let { columns, expandedRows, isGrid } = this.layout
×
395
    let { itemHeight, expansionPadding } = this.props
×
396
    let { height } = this.state
×
397

398
    let top = this.container.current.scrollTop
×
399
    let row = Math.floor(cursor / columns)
×
400

401
    let { numRowsAbove } =
402
        getExpandedRowsAbove(expandedRows, { index: row })
×
403

404
    let offset = (row + numRowsAbove + expRowPosition) * itemHeight
×
405

406
    if (isGrid && numRowsAbove > 0)
×
407
      offset += expansionPadding
×
408

409
    let bottom = offset + itemHeight
×
410
    let isBelow = bottom > top
×
411

412
    // Don't scroll if item already in viewport!
413
    if (!force && isBelow && bottom <= top + height)
×
414
      return
×
415

416
    if (isBelow)
×
417
      offset += itemHeight - height
×
418

419
    this.scroll(offset)
×
420
  }
421

422
  sync(...args) {
423
    this.container.current.sync(...args)
×
424
  }
425

426
  render() {
427
    this.layout = this.getComputedLayout(
2✔
428
      this.props.items,
429
      this.props.itemHeight,
430
      this.props.itemWidth,
431
      this.state.height,
432
      this.state.width,
433
      this.props.expandedItems,
434
      this.props.expansionPadding,
435
      this.props.overscan)
436

437
    let { columns, rowsPerPage, runway } = this.layout
2✔
438
    let { row, numRowsAbove } = this.state
2✔
439

440
    let from = columns * Math.max(0, row - numRowsAbove)
2✔
441
    let to = Math.min(from + (columns * rowsPerPage), this.props.items.length)
2✔
442

443
    return (
2✔
444
      <ScrollContainer
445
        ref={this.container}
446
        sync={this.props.sync}
447
        onClick={this.props.onClick}
448
        onKeyDown={this.handleKeyDown}
449
        onResize={this.handleResize}
450
        onScroll={this.handleScroll}
451
        onScrollStart={this.handleScrollStart}
452
        onScrollStop={this.handleScrollStop}
453
        onTabFocus={this.handleTabFocus}
454
        tabIndex={this.tabIndex}>
455
        <Runway height={runway}>
456
          <Viewport
457
            tag={this.props.tag}
458
            columns={columns}
459
            offset={this.state.offset}>
460
            <Range
461
              isScrolling={this.state.isScrolling}
462
              columns={columns}
463
              items={this.props.items}
464
              from={from}
465
              to={to}
466
              expandedItems={this.props.expandedItems}
467
              renderExpansionRow={this.props.renderExpansionRow}
468
              renderItem={this.props.children}/>
469
          </Viewport>
470
        </Runway>
471
      </ScrollContainer>
472
    )
473
  }
474

475
  static propTypes = {
5✔
476
    autoselect: bool,
477
    children: func.isRequired,
478
    cursor: number,
479
    expandedItems: object.isRequired,
480
    expansionPadding: number.isRequired,
481
    expansionCursor: number,
482
    items: array.isRequired,
483
    itemWidth: number,
484
    itemHeight: number.isRequired,
485
    onClick: func,
486
    onKeyDown: func,
487
    onTabFocus: func,
488
    onSelect: func,
489
    overscan: number.isRequired,
490
    renderExpansionRow: func,
491
    restrict: oneOf(['bounds', 'wrap', 'none']).isRequired,
492
    sync: object,
493
    tabIndex: number,
494
    tag: string
495
  }
496

497
  static defaultProps = {
5✔
498
    expandedItems: {},
499
    expansionPadding: 0,
500
    items: [],
501
    overscan: 1.25,
502
    restrict: 'bounds'
503
  }
504
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc