@rip-lang/ui 0.3.19 → 0.3.21

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 (61) hide show
  1. package/README.md +443 -576
  2. package/accordion.rip +113 -0
  3. package/alert-dialog.rip +96 -0
  4. package/autocomplete.rip +141 -0
  5. package/avatar.rip +37 -0
  6. package/badge.rip +15 -0
  7. package/breadcrumb.rip +46 -0
  8. package/button-group.rip +26 -0
  9. package/button.rip +23 -0
  10. package/card.rip +25 -0
  11. package/carousel.rip +110 -0
  12. package/checkbox-group.rip +65 -0
  13. package/checkbox.rip +33 -0
  14. package/collapsible.rip +50 -0
  15. package/combobox.rip +155 -0
  16. package/context-menu.rip +105 -0
  17. package/date-picker.rip +214 -0
  18. package/dialog.rip +107 -0
  19. package/drawer.rip +79 -0
  20. package/editable-value.rip +80 -0
  21. package/field.rip +53 -0
  22. package/fieldset.rip +22 -0
  23. package/form.rip +39 -0
  24. package/grid.rip +901 -0
  25. package/index.rip +16 -0
  26. package/input-group.rip +28 -0
  27. package/input.rip +36 -0
  28. package/label.rip +16 -0
  29. package/menu.rip +162 -0
  30. package/menubar.rip +155 -0
  31. package/meter.rip +36 -0
  32. package/multi-select.rip +158 -0
  33. package/native-select.rip +32 -0
  34. package/nav-menu.rip +129 -0
  35. package/number-field.rip +162 -0
  36. package/otp-field.rip +89 -0
  37. package/package.json +18 -27
  38. package/pagination.rip +123 -0
  39. package/popover.rip +143 -0
  40. package/preview-card.rip +73 -0
  41. package/progress.rip +25 -0
  42. package/radio-group.rip +67 -0
  43. package/resizable.rip +123 -0
  44. package/scroll-area.rip +145 -0
  45. package/select.rip +184 -0
  46. package/separator.rip +17 -0
  47. package/skeleton.rip +22 -0
  48. package/slider.rip +165 -0
  49. package/spinner.rip +17 -0
  50. package/table.rip +27 -0
  51. package/tabs.rip +124 -0
  52. package/textarea.rip +48 -0
  53. package/toast.rip +87 -0
  54. package/toggle-group.rip +78 -0
  55. package/toggle.rip +24 -0
  56. package/toolbar.rip +46 -0
  57. package/tooltip.rip +115 -0
  58. package/dist/rip-ui.min.js +0 -524
  59. package/dist/rip-ui.min.js.br +0 -0
  60. package/serve.rip +0 -92
  61. package/ui.rip +0 -964
package/grid.rip ADDED
@@ -0,0 +1,901 @@
1
+ # Grid — high-performance headless data grid with virtual scrolling
2
+ #
3
+ # Renders 100K+ rows at 60fps. Semantic <table> with sticky header.
4
+ # Selection, keyboard navigation, inline editing, sorting, column resize.
5
+ # Exposes data-* attributes for all interactive states. Themeable via
6
+ # CSS custom properties (--grid-*). Ships zero layout CSS — bring your own.
7
+ #
8
+ # Usage:
9
+ # Grid
10
+ # data: employees
11
+ # columns: [
12
+ # { key: 'name', title: 'Name', width: 200 }
13
+ # { key: 'age', title: 'Age', width: 80, align: 'right' }
14
+ # { key: 'role', title: 'Role', width: 150, type: 'select', source: roles }
15
+ # { key: 'active', title: 'Active', width: 60, type: 'checkbox' }
16
+ # ]
17
+ # rowHeight: 32
18
+ # overscan: 5
19
+ # @beforeEdit: (row, col, oldVal, newVal) -> newVal
20
+ # @afterEdit: (row, col, oldVal, newVal) -> null
21
+
22
+ export Grid = component
23
+
24
+ @data := []
25
+ @columns := []
26
+ @rowHeight := 32
27
+ @headerHeight := 36
28
+ @overscan := 5
29
+ @striped := false
30
+ @beforeEdit := null
31
+ @afterEdit := null
32
+
33
+ formatRegistry = []
34
+
35
+ registerFormat = (code, formatFn, parseFn) ->
36
+ id = formatRegistry.length
37
+ formatRegistry.push({ code, format: formatFn, parse: parseFn })
38
+ id
39
+
40
+ formatMap := new Map()
41
+
42
+ _resolveCol = (ci) ->
43
+ fid = formatMap.get("0,#{ci + 1}")
44
+ if fid isnt undefined then return formatRegistry[fid]
45
+ gid = formatMap.get("0,0")
46
+ if gid isnt undefined then formatRegistry[gid] else null
47
+
48
+ colFormatCache ~=
49
+ cache = []
50
+ ci = 0
51
+ while ci < @columns.length
52
+ cache.push(_resolveCol(ci))
53
+ ci++
54
+ cache
55
+
56
+ cellFormat = (row, col) ->
57
+ fid = formatMap.get("#{row + 1},#{col + 1}")
58
+ if fid isnt undefined then formatRegistry[fid] else colFormatCache[col]
59
+
60
+ _ready := false
61
+ scrollTop := 0
62
+ containerHeight := 0
63
+ activeRow := -1
64
+ activeCol := -1
65
+ anchorRow := -1
66
+ anchorCol := -1
67
+ selecting := false
68
+ editing := false
69
+ editValue := ''
70
+ _enterCommit = false
71
+ dataVersion := 0
72
+ sortKeys := []
73
+
74
+ cmp = (a, b) -> (a > b) - (a < b)
75
+
76
+ sortIndex ~=
77
+ dataVersion
78
+ len = @data.length
79
+ idx = new Array(len)
80
+ i = 0
81
+ while i < len
82
+ idx[i] = i
83
+ i++
84
+ if sortKeys.length > 0
85
+ idx.sort (a, b) ->
86
+ ki = 0
87
+ while ki < sortKeys.length
88
+ sk = sortKeys[ki]
89
+ va = @data[a][@columns[sk.col].key]
90
+ vb = @data[b][@columns[sk.col].key]
91
+ result = if sk.dir is 'asc' then cmp(va, vb) else cmp(vb, va)
92
+ if result isnt 0 then return result
93
+ ki++
94
+ a - b
95
+ idx
96
+
97
+ sortInfo ~=
98
+ arrows = new Array(@columns.length).fill('')
99
+ ranks = new Array(@columns.length).fill(0)
100
+ ki = 0
101
+ while ki < sortKeys.length
102
+ sk = sortKeys[ki]
103
+ arrows[sk.col] = if sk.dir is 'asc' then " ↓" else " ↑"
104
+ ranks[sk.col] = ki + 1
105
+ ki++
106
+ { arrows, ranks }
107
+
108
+ cellRef ~= if activeRow >= 0 and activeCol >= 0 then "R#{activeRow + 1}:C#{activeCol + 1}" else ''
109
+ totalRows ~= @data.length
110
+ startRow ~= Math.max(0, Math.floor(scrollTop / @rowHeight) - @overscan)
111
+ endRow ~= Math.min(totalRows - 1, Math.ceil((scrollTop + containerHeight) / @rowHeight) + @overscan)
112
+ offsetY ~= startRow * @rowHeight
113
+ bottomSpace ~= Math.max(0, (totalRows - endRow - 1) * @rowHeight)
114
+
115
+ # --------------------------------------------------------------------------
116
+ # DOM Recycling — pooled <tr>/<td> elements, imperative updates on scroll
117
+ # --------------------------------------------------------------------------
118
+ #
119
+ # Instead of letting the reactive render loop recreate row components on
120
+ # every scroll frame, we maintain a fixed pool of <tr> elements and update
121
+ # cell textContent directly. This eliminates DOM allocation and GC pressure
122
+ # during fast scrolling — the most performance-critical path in the grid.
123
+
124
+ _trPool = []
125
+ _topSpacer = null
126
+ _botSpacer = null
127
+ _selBorder = null
128
+
129
+ _createSpacer: ->
130
+ tr = document.createElement('tr')
131
+ tr.className = 'spacer'
132
+ td = document.createElement('td')
133
+ td.style.padding = '0'
134
+ td.style.border = 'none'
135
+ td.style.lineHeight = '0'
136
+ td.style.fontSize = '0'
137
+ tr.appendChild(td)
138
+ tr
139
+
140
+ _ensurePool: (needed, numCols) ->
141
+ while _trPool.length < needed
142
+ tr = document.createElement('tr')
143
+ ci = 0
144
+ while ci < numCols
145
+ td = document.createElement('td')
146
+ tr.appendChild(td)
147
+ ci++
148
+ _trPool.push(tr)
149
+ # Add cells if column count grew
150
+ for tr in _trPool
151
+ while tr.children.length < numCols
152
+ tr.appendChild(document.createElement('td'))
153
+
154
+ # Effect: update tbody rows from pool on every viewport/data change
155
+ ~>
156
+ return unless _ready
157
+ tbody = @_body
158
+ return unless tbody
159
+ numCols = @columns.length
160
+ count = endRow - startRow + 1
161
+ count = 0 if count < 0
162
+
163
+ @_ensurePool(count, numCols)
164
+
165
+ _topSpacer.children[0].colSpan = numCols
166
+ _topSpacer.children[0].style.height = "#{offsetY}px"
167
+ _botSpacer.children[0].colSpan = numCols
168
+ _botSpacer.children[0].style.height = "#{bottomSpace}px"
169
+
170
+ # Detach all current rows (fast: replaceChildren rebuilds in one
171
+ # reflow instead of N removeChild calls)
172
+ rows = [_topSpacer]
173
+
174
+ i = 0
175
+ n = startRow
176
+ while n <= endRow
177
+ if n >= 0 and n < totalRows
178
+ tr = _trPool[i]
179
+ di = sortIndex[n]
180
+ row = @data[di]
181
+
182
+ # Stripe class
183
+ if @striped and n % 2 is 1
184
+ tr.className = 'even'
185
+ else
186
+ tr.className = ''
187
+
188
+ # Update cells — only textContent, the cheapest DOM mutation
189
+ ci = 0
190
+ while ci < numCols
191
+ td = tr.children[ci]
192
+ c = @columns[ci]
193
+ v = row[c.key]
194
+ fmt = cellFormat(di, ci)
195
+ display = if fmt then fmt.format(v) else v
196
+
197
+ td.style.textAlign = c.align or 'left'
198
+
199
+ if c.type is 'checkbox'
200
+ # Checkbox cells need an <input> — reuse if present
201
+ inp = td.firstChild
202
+ unless inp and inp.tagName is 'INPUT'
203
+ td.textContent = ''
204
+ inp = document.createElement('input')
205
+ inp.type = 'checkbox'
206
+ inp.className = 'cell-checkbox'
207
+ inp.style.pointerEvents = 'none'
208
+ inp.style.margin = '0'
209
+ inp.style.verticalAlign = 'middle'
210
+ td.appendChild(inp)
211
+ inp.checked = !!v
212
+ else
213
+ # Plain text — fastest path
214
+ if td.firstChild and td.firstChild.nodeType is 3
215
+ td.firstChild.nodeValue = if display? then String(display) else ''
216
+ else
217
+ td.textContent = if display? then String(display) else ''
218
+
219
+ ci++
220
+ rows.push(tr)
221
+ i++
222
+ n++
223
+
224
+ rows.push(_botSpacer)
225
+ tbody.replaceChildren(...rows)
226
+
227
+ # Selection overlay — applies data-* attributes to active/selected cells
228
+ ~>
229
+ return unless _ready
230
+ tbody = @_body
231
+ return unless tbody
232
+ trs = tbody.querySelectorAll('tr:not(.spacer)')
233
+
234
+ for el in tbody.querySelectorAll('td[data-active], td[data-selected]')
235
+ delete el.dataset.active
236
+ delete el.dataset.selected
237
+ el.style.borderColor = ''
238
+
239
+ unless _selBorder
240
+ _selBorder = document.createElement('div')
241
+ _selBorder.style.cssText = 'position:absolute;pointer-events:none;border:0;outline:1px solid #3b82f6;outline-offset:-1px;z-index:1;display:none'
242
+ @_container.appendChild(_selBorder)
243
+
244
+ if anchorRow >= 0 and activeRow >= 0 and anchorCol >= 0 and activeCol >= 0
245
+ minR = Math.min(anchorRow, activeRow)
246
+ maxR = Math.max(anchorRow, activeRow)
247
+ minC = Math.min(anchorCol, activeCol)
248
+ maxC = Math.max(anchorCol, activeCol)
249
+ multi = minR isnt maxR or minC isnt maxC
250
+ r = Math.max(minR, startRow)
251
+ while r <= Math.min(maxR, endRow)
252
+ tr = trs[r - startRow]
253
+ if tr
254
+ c = minC
255
+ while c <= maxC
256
+ td = tr.children[c]
257
+ if td
258
+ td.dataset.selected = ''
259
+ td.style.borderColor = '#bfdbfe'
260
+ c++
261
+ r++
262
+
263
+ if multi and not selecting
264
+ topTr = trs[Math.max(minR, startRow) - startRow]
265
+ botTr = trs[Math.min(maxR, endRow) - startRow]
266
+ if topTr and botTr
267
+ topTd = topTr.children[minC]
268
+ botTd = botTr.children[maxC]
269
+ if topTd and botTd
270
+ cRect = @_container.getBoundingClientRect()
271
+ tl = topTd.getBoundingClientRect()
272
+ br = botTd.getBoundingClientRect()
273
+ _selBorder.style.top = "#{tl.top - cRect.top + @_container.scrollTop}px"
274
+ _selBorder.style.left = "#{tl.left - cRect.left + @_container.scrollLeft}px"
275
+ _selBorder.style.width = "#{br.right - tl.left}px"
276
+ _selBorder.style.height = "#{br.bottom - tl.top}px"
277
+ _selBorder.style.display = 'block'
278
+ else
279
+ _selBorder.style.display = 'none'
280
+ else
281
+ _selBorder.style.display = 'none'
282
+ else
283
+ _selBorder.style.display = 'none'
284
+ else
285
+ _selBorder.style.display = 'none'
286
+
287
+ cursorRow = if selecting then anchorRow else activeRow
288
+ cursorCol = if selecting then anchorCol else activeCol
289
+ if cursorRow >= startRow and cursorRow <= endRow and cursorCol >= 0
290
+ tr = trs[cursorRow - startRow]
291
+ if tr
292
+ td = tr.children[cursorCol]
293
+ if td
294
+ td.dataset.active = ''
295
+
296
+ _removeEditor: ->
297
+ @_container.querySelector('.rip-grid-editor')?.remove()
298
+
299
+ openEditor: (initial, cursorEnd) ->
300
+ if activeRow < 0 or activeCol < 0 or editing
301
+ return
302
+ col = @columns[activeCol]
303
+ if col.type is 'checkbox'
304
+ @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]
305
+ dataVersion++
306
+ return
307
+ val = @data[sortIndex[activeRow]][col.key]
308
+ orig = String(val ?? '')
309
+ if initial isnt undefined and col.type isnt 'select'
310
+ editValue = initial
311
+ else
312
+ editValue = orig
313
+ editing = true
314
+ @_removeEditor()
315
+ el = undefined
316
+ if col.type is 'select'
317
+ el = document.createElement('select')
318
+ el.className = 'rip-grid-editor'
319
+ el.style.borderRadius = '0'
320
+ for opt in (col.source or [])
321
+ o = document.createElement('option')
322
+ o.value = opt
323
+ o.textContent = opt
324
+ o.selected = (opt is editValue)
325
+ el.appendChild(o)
326
+ el.addEventListener 'change', (e) =>
327
+ editValue = e.target.value
328
+ @commitEditor()
329
+ el.addEventListener 'keydown', (e) => @_editorKeydown(e)
330
+ else
331
+ el = document.createElement('input')
332
+ el.className = 'rip-grid-editor'
333
+ el.type = 'text'
334
+ el.value = editValue
335
+ align = col.align or 'left'
336
+ el.style.textAlign = align
337
+ el.addEventListener 'input', (e) =>
338
+ editValue = e.target.value
339
+ el.addEventListener 'keydown', (e) => @_editorKeydown(e)
340
+ @_container.appendChild(el)
341
+ requestAnimationFrame =>
342
+ trs = @_body.querySelectorAll('tr:not(.spacer)')
343
+ tr = trs[activeRow - startRow]
344
+ if tr
345
+ td = tr.children[activeCol]
346
+ if td
347
+ tdRect = td.getBoundingClientRect()
348
+ cRect = @_container.getBoundingClientRect()
349
+ el.style.top = "#{tdRect.top - cRect.top + @_container.scrollTop}px"
350
+ el.style.left = "#{tdRect.left - cRect.left + @_container.scrollLeft}px"
351
+ el.style.width = "#{tdRect.width}px"
352
+ el.style.height = "#{tdRect.height}px"
353
+ if el.selectionStart isnt undefined
354
+ el.selectionStart = el.value.length
355
+ el.selectionEnd = el.value.length
356
+ el.focus()
357
+
358
+ commitEditor: ->
359
+ if editing
360
+ di = sortIndex[activeRow]
361
+ col = @columns[activeCol]
362
+ oldVal = @data[di][col.key]
363
+ val = editValue
364
+ fmt = cellFormat(di, activeCol)
365
+ if fmt and fmt.parse
366
+ val = fmt.parse(val)
367
+ if @beforeEdit
368
+ val = @beforeEdit(di, activeCol, oldVal, val)
369
+ if val is false then return @cancelEditor()
370
+ @data[di][col.key] = val
371
+ dataVersion++
372
+ editing = false
373
+ @_removeEditor()
374
+ @_container.focus() if @_container
375
+ if @afterEdit
376
+ @afterEdit(di, activeCol, oldVal, val)
377
+
378
+ cancelEditor: ->
379
+ editing = false
380
+ @_removeEditor()
381
+ anchorRow = activeRow
382
+ anchorCol = activeCol
383
+ @_container.focus() if @_container
384
+
385
+ _editorKeydown: (e) ->
386
+ numCols = @columns.length
387
+ switch e.key
388
+ when 'Enter'
389
+ e.preventDefault()
390
+ e.stopPropagation()
391
+ @commitEditor()
392
+ _enterCommit = true
393
+ when 'Tab'
394
+ e.preventDefault()
395
+ e.stopPropagation()
396
+ @commitEditor()
397
+ if e.shiftKey
398
+ if activeCol > 0
399
+ activeCol = activeCol - 1
400
+ else if activeRow > 0
401
+ activeRow = activeRow - 1
402
+ activeCol = numCols - 1
403
+ @scrollToRow(activeRow)
404
+ else
405
+ if activeCol < numCols - 1
406
+ activeCol = activeCol + 1
407
+ else if activeRow < totalRows - 1
408
+ activeRow = activeRow + 1
409
+ activeCol = 0
410
+ @scrollToRow(activeRow)
411
+ anchorRow = activeRow
412
+ anchorCol = activeCol
413
+ when 'Escape'
414
+ e.preventDefault()
415
+ e.stopPropagation()
416
+ @cancelEditor()
417
+
418
+ _resizeStart: (e) ->
419
+ e.stopPropagation()
420
+ e.preventDefault()
421
+ th = e.target.closest('th')
422
+ if not th then return
423
+ ci = th.cellIndex
424
+ startX = e.clientX
425
+ startW = @columns[ci].width
426
+ handle = e.target
427
+ handle.classList.add('active')
428
+ colEl = @_container.querySelector("colgroup").children[ci]
429
+ onMove = (ev) =>
430
+ w = Math.max(40, startW + ev.clientX - startX)
431
+ colEl.style.width = "#{w}px"
432
+ onUp = (ev) =>
433
+ document.removeEventListener('mousemove', onMove)
434
+ document.removeEventListener('mouseup', onUp)
435
+ handle.classList.remove('active')
436
+ w = Math.max(40, startW + ev.clientX - startX)
437
+ @columns = @columns.map (c, i) -> if i is ci then Object.assign({}, c, { width: w }) else c
438
+ document.addEventListener('mousemove', onMove)
439
+ document.addEventListener('mouseup', onUp)
440
+
441
+ _headerClick: (e) ->
442
+ if e.target.classList.contains('resize-handle') then return
443
+ th = e.target.closest('th')
444
+ if not th then return
445
+ ci = th.cellIndex
446
+ if e.shiftKey
447
+ existing = sortKeys.findIndex (sk) -> sk.col is ci
448
+ if existing >= 0
449
+ if sortKeys[existing].dir is 'asc'
450
+ sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'desc' } else sk
451
+ else if sortKeys.length > 1
452
+ sortKeys = sortKeys.map (sk, i) -> if i is existing then { col: sk.col, dir: 'asc' } else sk
453
+ else
454
+ sortKeys = sortKeys.filter (_, i) -> i isnt existing
455
+ else
456
+ sortKeys = sortKeys.concat({ col: ci, dir: 'asc' })
457
+ else
458
+ if sortKeys.length is 1 and sortKeys[0].col is ci
459
+ if sortKeys[0].dir is 'asc'
460
+ sortKeys = [{ col: ci, dir: 'desc' }]
461
+ else
462
+ sortKeys = []
463
+ else
464
+ sortKeys = [{ col: ci, dir: 'asc' }]
465
+ dataVersion++
466
+
467
+ onDblclick: (e) ->
468
+ td = e.target.closest('td')
469
+ if td and not td.parentElement.classList.contains('spacer')
470
+ @openEditor()
471
+
472
+ onScroll: (e) ->
473
+ @commitEditor() if editing
474
+ @_nextST = e.currentTarget.scrollTop
475
+ if not @_rafPending
476
+ @_rafPending = true
477
+ requestAnimationFrame =>
478
+ scrollTop = @_nextST
479
+ @_rafPending = false
480
+
481
+ scrollToRow: (row) ->
482
+ el = @_container
483
+ if el
484
+ top = row * @rowHeight
485
+ if top < el.scrollTop
486
+ el.scrollTop = top
487
+ else if top + @rowHeight + @headerHeight > el.scrollTop + containerHeight
488
+ el.scrollTop = top + @rowHeight + @headerHeight - containerHeight
489
+
490
+ onMousedown: (e) ->
491
+ if e.button isnt 0
492
+ return
493
+ if e.target.classList.contains('rip-grid-editor')
494
+ return
495
+ @commitEditor() if editing
496
+ td = e.target.closest('td')
497
+ if td and not td.parentElement.classList.contains('spacer')
498
+ e.preventDefault()
499
+ @_container.focus() if @_container
500
+ selecting = true
501
+ el = @_container
502
+ rect = el.getBoundingClientRect()
503
+ ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)
504
+ if ri >= 0 and ri < totalRows
505
+ activeRow = ri
506
+ activeCol = td.cellIndex
507
+ if not e.shiftKey
508
+ anchorRow = ri
509
+ anchorCol = td.cellIndex
510
+
511
+ onMouseup: (e) ->
512
+ if selecting
513
+ r = anchorRow; c = anchorCol
514
+ anchorRow = activeRow; anchorCol = activeCol
515
+ activeRow = r; activeCol = c
516
+ selecting = false
517
+
518
+ onMousemove: (e) ->
519
+ if not selecting or e.buttons isnt 1
520
+ return
521
+ el = @_container
522
+ rect = el.getBoundingClientRect()
523
+ ri = Math.floor((e.clientY - rect.top + el.scrollTop - @headerHeight) / @rowHeight)
524
+ ri = Math.max(0, Math.min(totalRows - 1, ri))
525
+ td = e.target.closest('td')
526
+ ci = if td and not td.parentElement.classList.contains('spacer') then td.cellIndex else activeCol
527
+ activeRow = ri
528
+ activeCol = ci
529
+ @scrollToRow(ri)
530
+
531
+ _jumpToEdge: (row, col, dr, dc) ->
532
+ key = @columns[col]?.key
533
+ return { row, col } unless key
534
+ cur = @data[sortIndex[row]]?[key]
535
+ empty = not cur? or cur is ''
536
+ r = row + dr
537
+ c = col + dc
538
+ while r >= 0 and r < totalRows and c >= 0 and c < @columns.length
539
+ k = @columns[c].key
540
+ v = @data[sortIndex[r]]?[k]
541
+ ve = not v? or v is ''
542
+ if empty
543
+ return { row: r, col: c } unless ve
544
+ else
545
+ if ve then return { row: r - dr, col: c - dc }
546
+ r += dr
547
+ c += dc
548
+ row: Math.max(0, Math.min(totalRows - 1, r - dr))
549
+ col: Math.max(0, Math.min(@columns.length - 1, c - dc))
550
+
551
+ onKeydown: (e) ->
552
+ if editing
553
+ return
554
+ numCols = @columns.length
555
+ key = e.key
556
+ _enterCommit = false if key isnt 'Enter'
557
+ switch key
558
+ when 'ArrowDown'
559
+ e.preventDefault()
560
+ if activeRow < 0
561
+ activeRow = 0
562
+ activeCol = 0 if activeCol < 0
563
+ else if e.ctrlKey or e.metaKey
564
+ pos = @_jumpToEdge(activeRow, activeCol, 1, 0)
565
+ activeRow = pos.row
566
+ else if activeRow < totalRows - 1
567
+ activeRow = activeRow + 1
568
+ @scrollToRow(activeRow)
569
+ when 'ArrowUp'
570
+ e.preventDefault()
571
+ if e.ctrlKey or e.metaKey
572
+ pos = @_jumpToEdge(activeRow, activeCol, -1, 0)
573
+ activeRow = pos.row
574
+ activeCol = 0 if activeCol < 0
575
+ else if activeRow > 0
576
+ activeRow = activeRow - 1
577
+ @scrollToRow(activeRow)
578
+ when 'ArrowRight'
579
+ e.preventDefault()
580
+ if activeCol < 0
581
+ activeCol = 0
582
+ activeRow = 0 if activeRow < 0
583
+ else if e.ctrlKey or e.metaKey
584
+ pos = @_jumpToEdge(activeRow, activeCol, 0, 1)
585
+ activeCol = pos.col
586
+ else if activeCol < numCols - 1
587
+ activeCol = activeCol + 1
588
+ when 'ArrowLeft'
589
+ e.preventDefault()
590
+ if e.ctrlKey or e.metaKey
591
+ pos = @_jumpToEdge(activeRow, activeCol, 0, -1)
592
+ activeCol = pos.col
593
+ activeRow = 0 if activeRow < 0
594
+ else if activeCol > 0
595
+ activeCol = activeCol - 1
596
+ when 'Home'
597
+ e.preventDefault()
598
+ if e.ctrlKey or e.metaKey
599
+ activeRow = 0
600
+ activeCol = 0
601
+ @scrollToRow(0)
602
+ else
603
+ activeRow = 0 if activeRow < 0
604
+ activeCol = 0
605
+ when 'End'
606
+ e.preventDefault()
607
+ if e.ctrlKey or e.metaKey
608
+ activeRow = totalRows - 1
609
+ activeCol = numCols - 1
610
+ @scrollToRow(activeRow)
611
+ else
612
+ activeRow = 0 if activeRow < 0
613
+ activeCol = numCols - 1
614
+ when 'Tab'
615
+ e.preventDefault()
616
+ if activeRow < 0 or activeCol < 0
617
+ activeRow = 0
618
+ activeCol = 0
619
+ else if e.shiftKey
620
+ if activeCol > 0
621
+ activeCol = activeCol - 1
622
+ else if activeRow > 0
623
+ activeRow = activeRow - 1
624
+ activeCol = numCols - 1
625
+ @scrollToRow(activeRow)
626
+ else
627
+ if activeCol < numCols - 1
628
+ activeCol = activeCol + 1
629
+ else if activeRow < totalRows - 1
630
+ activeRow = activeRow + 1
631
+ activeCol = 0
632
+ @scrollToRow(activeRow)
633
+ when 'Enter'
634
+ e.preventDefault()
635
+ if activeRow < 0 or activeCol < 0
636
+ activeRow = 0
637
+ activeCol = 0
638
+ else if _enterCommit
639
+ _enterCommit = false
640
+ if e.shiftKey
641
+ if activeRow > 0
642
+ activeRow = activeRow - 1
643
+ anchorRow = activeRow
644
+ @scrollToRow(activeRow)
645
+ else
646
+ if activeRow < totalRows - 1
647
+ activeRow = activeRow + 1
648
+ anchorRow = activeRow
649
+ @scrollToRow(activeRow)
650
+ @openEditor()
651
+ return
652
+ else
653
+ @openEditor()
654
+ return
655
+ when 'F2'
656
+ e.preventDefault()
657
+ if activeRow >= 0 and activeCol >= 0
658
+ @openEditor(undefined, true)
659
+ return
660
+ when 'PageDown'
661
+ e.preventDefault()
662
+ rows = Math.floor(containerHeight / @rowHeight)
663
+ activeRow = Math.min(totalRows - 1, activeRow + rows)
664
+ activeCol = 0 if activeCol < 0
665
+ @scrollToRow(activeRow)
666
+ when 'PageUp'
667
+ e.preventDefault()
668
+ rows = Math.floor(containerHeight / @rowHeight)
669
+ activeRow = Math.max(0, activeRow - rows)
670
+ activeCol = 0 if activeCol < 0
671
+ @scrollToRow(activeRow)
672
+ when 'Escape'
673
+ if anchorRow isnt activeRow or anchorCol isnt activeCol
674
+ anchorRow = activeRow
675
+ anchorCol = activeCol
676
+ else
677
+ activeRow = -1
678
+ activeCol = -1
679
+ anchorRow = -1
680
+ anchorCol = -1
681
+ return
682
+ when ' '
683
+ if activeRow >= 0 and activeCol >= 0
684
+ col = @columns[activeCol]
685
+ if col.type is 'checkbox'
686
+ e.preventDefault()
687
+ @data[sortIndex[activeRow]][col.key] = not @data[sortIndex[activeRow]][col.key]
688
+ dataVersion++
689
+ return
690
+ when 'Delete', 'Backspace'
691
+ if activeRow >= 0 and activeCol >= 0 and not e.ctrlKey and not e.metaKey
692
+ e.preventDefault()
693
+ col = @columns[activeCol]
694
+ if col.type is 'checkbox'
695
+ @data[sortIndex[activeRow]][col.key] = false
696
+ else
697
+ fmt = cellFormat(sortIndex[activeRow], activeCol)
698
+ @data[sortIndex[activeRow]][col.key] = if fmt and fmt.parse then fmt.parse('') else ''
699
+ dataVersion++
700
+ return
701
+ when 'a'
702
+ if e.ctrlKey or e.metaKey
703
+ e.preventDefault()
704
+ anchorRow = 0
705
+ anchorCol = 0
706
+ activeRow = totalRows - 1
707
+ activeCol = numCols - 1
708
+ return
709
+ when 'c'
710
+ if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0
711
+ e.preventDefault()
712
+ @copySelection()
713
+ return
714
+ when 'v'
715
+ if e.ctrlKey or e.metaKey
716
+ e.preventDefault()
717
+ @pasteAtActive()
718
+ return
719
+ when 'x'
720
+ if (e.ctrlKey or e.metaKey) and activeRow >= 0 and activeCol >= 0
721
+ e.preventDefault()
722
+ @cutSelection()
723
+ return
724
+ else
725
+ if key.length is 1 and not e.ctrlKey and not e.metaKey and not e.altKey
726
+ if activeRow >= 0 and activeCol >= 0
727
+ ct = @columns[activeCol].type
728
+ if ct isnt 'checkbox' and ct isnt 'select'
729
+ e.preventDefault()
730
+ @openEditor(key)
731
+ return
732
+
733
+ extending = e.shiftKey and key isnt 'Tab' and key isnt 'Enter'
734
+ if not extending
735
+ anchorRow = activeRow
736
+ anchorCol = activeCol
737
+
738
+ # --------------------------------------------------------------------------
739
+ # Clipboard — copy, cut, paste via navigator.clipboard (TSV format)
740
+ # --------------------------------------------------------------------------
741
+
742
+ _selectionBounds: ->
743
+ minR = Math.min(anchorRow, activeRow)
744
+ maxR = Math.max(anchorRow, activeRow)
745
+ minC = Math.min(anchorCol, activeCol)
746
+ maxC = Math.max(anchorCol, activeCol)
747
+ { minR, maxR, minC, maxC }
748
+
749
+ _selectionToTSV: ->
750
+ { minR, maxR, minC, maxC } = @_selectionBounds()
751
+ lines = []
752
+ r = minR
753
+ while r <= maxR
754
+ di = sortIndex[r]
755
+ row = @data[di]
756
+ cells = []
757
+ c = minC
758
+ while c <= maxC
759
+ v = row[@columns[c].key]
760
+ s = if v? then String(v) else ''
761
+ if s.includes('\t') or s.includes('\n') or s.includes('"')
762
+ s = '"' + s.replace(/"/g, '""') + '"'
763
+ cells.push(s)
764
+ c++
765
+ lines.push(cells.join('\t'))
766
+ r++
767
+ lines.join('\n')
768
+
769
+ _parseTSV: (text) ->
770
+ rows = []
771
+ lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n')
772
+ for line, li in lines
773
+ continue if line is '' and li is lines.length - 1
774
+ if line.includes('"')
775
+ cells = []
776
+ i = 0
777
+ while i < line.length
778
+ if line[i] is '"'
779
+ j = i + 1
780
+ val = ''
781
+ while j < line.length
782
+ if line[j] is '"'
783
+ if line[j + 1] is '"'
784
+ val += '"'
785
+ j += 2
786
+ else
787
+ j++
788
+ break
789
+ else
790
+ val += line[j]
791
+ j++
792
+ cells.push(val)
793
+ i = j + 1
794
+ else
795
+ j = line.indexOf('\t', i)
796
+ if j is -1
797
+ cells.push(line.slice(i))
798
+ break
799
+ else
800
+ cells.push(line.slice(i, j))
801
+ i = j + 1
802
+ rows.push(cells)
803
+ else
804
+ rows.push(line.split('\t'))
805
+ rows
806
+
807
+ copySelection: ->
808
+ return if activeRow < 0 or activeCol < 0
809
+ tsv = @_selectionToTSV()
810
+ navigator.clipboard.writeText(tsv) if navigator.clipboard
811
+
812
+ cutSelection: ->
813
+ return if activeRow < 0 or activeCol < 0
814
+ @copySelection()
815
+ { minR, maxR, minC, maxC } = @_selectionBounds()
816
+ r = minR
817
+ while r <= maxR
818
+ di = sortIndex[r]
819
+ c = minC
820
+ while c <= maxC
821
+ col = @columns[c]
822
+ if col.type is 'checkbox'
823
+ @data[di][col.key] = false
824
+ else
825
+ fmt = cellFormat(di, c)
826
+ @data[di][col.key] = if fmt and fmt.parse then fmt.parse('') else ''
827
+ c++
828
+ r++
829
+ dataVersion++
830
+
831
+ pasteAtActive: ->
832
+ return if activeRow < 0 or activeCol < 0
833
+ return unless navigator.clipboard
834
+ navigator.clipboard.readText().then (text) =>
835
+ return unless text
836
+ rows = @_parseTSV(text)
837
+ return unless rows.length
838
+ numCols = @columns.length
839
+ r = 0
840
+ while r < rows.length
841
+ ri = activeRow + r
842
+ break if ri >= totalRows
843
+ di = sortIndex[ri]
844
+ c = 0
845
+ while c < rows[r].length
846
+ ci = activeCol + c
847
+ break if ci >= numCols
848
+ col = @columns[ci]
849
+ val = rows[r][c]
850
+ if col.type is 'checkbox'
851
+ @data[di][col.key] = val is 'true' or val is '1' or val is 'yes'
852
+ else
853
+ fmt = cellFormat(di, ci)
854
+ @data[di][col.key] = if fmt and fmt.parse then fmt.parse(val) else val
855
+ c++
856
+ r++
857
+ dataVersion++
858
+
859
+ # Public API
860
+ getCell: (row, col) -> @data[row][@columns[col].key]
861
+ setCell: (row, col, value) -> @data[row][@columns[col].key] = value; dataVersion++
862
+ getData: -> @data
863
+ setData: (d) -> @data = d; dataVersion++
864
+ sort: (col, dir) -> sortKeys = if dir then [{ col, dir }] else []; dataVersion++
865
+
866
+ mounted: ->
867
+ _topSpacer = @_createSpacer()
868
+ _botSpacer = @_createSpacer()
869
+ _ready = true
870
+ if @_container
871
+ handler = (entries) =>
872
+ for entry in entries
873
+ containerHeight = entry.contentRect.height
874
+ @_resizeObs = new ResizeObserver(handler)
875
+ @_resizeObs.observe(@_container)
876
+
877
+ unmounted: ->
878
+ if @_resizeObs
879
+ @_resizeObs.disconnect()
880
+
881
+ render
882
+ div.grid-container ref: "_container", role: "grid", tabindex: "0",
883
+ $editing: editing?!,
884
+ $selecting: selecting?!
885
+ table.rip-grid
886
+ colgroup
887
+ for c in @columns
888
+ col style: "width: #{c.width}px"
889
+ thead
890
+ tr @click: @_headerClick
891
+ for c, ci in @columns
892
+ th style: "text-align: #{c.align or 'left'}; cursor: pointer",
893
+ $sorted: (if sortInfo.arrows[ci] then (if sortInfo.arrows[ci].includes('↓') then 'asc' else 'desc') else undefined)
894
+ = c.title
895
+ span.sort-arrow
896
+ sortInfo.arrows[ci]
897
+ if sortKeys.length > 1 and sortInfo.ranks[ci] > 0
898
+ span.sort-badge
899
+ "#{sortInfo.ranks[ci]}"
900
+ span.resize-handle @mousedown: @_resizeStart
901
+ tbody ref: "_body"