rip-lang 3.13.65 → 3.13.67

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 (49) hide show
  1. package/README.md +1 -1
  2. package/docs/dist/rip.js +28 -8
  3. package/docs/dist/rip.min.js +16 -16
  4. package/docs/dist/rip.min.js.br +0 -0
  5. package/docs/index.html +4 -0
  6. package/docs/ui/accordion.rip +113 -0
  7. package/docs/ui/autocomplete.rip +141 -0
  8. package/docs/ui/avatar.rip +37 -0
  9. package/docs/ui/button.rip +23 -0
  10. package/docs/ui/checkbox-group.rip +65 -0
  11. package/docs/ui/checkbox.rip +33 -0
  12. package/docs/ui/combobox.rip +155 -0
  13. package/docs/ui/context-menu.rip +105 -0
  14. package/docs/ui/date-picker.rip +214 -0
  15. package/docs/ui/dialog.rip +107 -0
  16. package/docs/ui/drawer.rip +79 -0
  17. package/docs/ui/editable-value.rip +80 -0
  18. package/docs/ui/field.rip +53 -0
  19. package/docs/ui/fieldset.rip +22 -0
  20. package/docs/ui/form.rip +39 -0
  21. package/docs/ui/grid.rip +901 -0
  22. package/docs/ui/index.css +1379 -0
  23. package/docs/ui/index.html +2097 -0
  24. package/docs/ui/input.rip +36 -0
  25. package/docs/ui/menu.rip +162 -0
  26. package/docs/ui/menubar.rip +155 -0
  27. package/docs/ui/meter.rip +36 -0
  28. package/docs/ui/multi-select.rip +158 -0
  29. package/docs/ui/nav-menu.rip +129 -0
  30. package/docs/ui/number-field.rip +162 -0
  31. package/docs/ui/otp-field.rip +89 -0
  32. package/docs/ui/popover.rip +143 -0
  33. package/docs/ui/preview-card.rip +73 -0
  34. package/docs/ui/progress.rip +25 -0
  35. package/docs/ui/radio-group.rip +67 -0
  36. package/docs/ui/scroll-area.rip +145 -0
  37. package/docs/ui/select.rip +184 -0
  38. package/docs/ui/separator.rip +17 -0
  39. package/docs/ui/slider.rip +165 -0
  40. package/docs/ui/tabs.rip +124 -0
  41. package/docs/ui/toast.rip +87 -0
  42. package/docs/ui/toggle-group.rip +78 -0
  43. package/docs/ui/toggle.rip +24 -0
  44. package/docs/ui/toolbar.rip +46 -0
  45. package/docs/ui/tooltip.rip +115 -0
  46. package/package.json +2 -1
  47. package/src/app.rip +1 -1
  48. package/src/components.js +3 -4
  49. package/src/lexer.js +1 -1
@@ -0,0 +1,162 @@
1
+ # NumberField — accessible headless number input with stepper buttons
2
+ #
3
+ # Increment/decrement with click, hold-to-repeat, and keyboard.
4
+ # Supports min/max/step clamping and Shift/Alt step modifiers.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # NumberField value <=> quantity
9
+ # NumberField value <=> price, min: 0, max: 1000, step: 0.01
10
+
11
+ START_DELAY = 400
12
+ TICK_DELAY = 60
13
+
14
+ export NumberField = component
15
+ @value := 0
16
+ @min := null
17
+ @max := null
18
+ @step := 1
19
+ @smallStep := 0.1
20
+ @largeStep := 10
21
+ @disabled := false
22
+ @readOnly := false
23
+ @name := null
24
+
25
+ _timer = null
26
+ _interval = null
27
+ _id =! "nf-#{Math.random().toString(36).slice(2, 8)}"
28
+
29
+ _clamp: (v) ->
30
+ v = Math.max(@min, v) if @min?
31
+ v = Math.min(@max, v) if @max?
32
+ v
33
+
34
+ _roundToStep: (v) ->
35
+ base = @min ?? 0
36
+ rounded = Math.round((v - base) / @step) * @step + base
37
+ precision = String(@step).split('.')[1]?.length or 0
38
+ parseFloat rounded.toFixed(precision)
39
+
40
+ _stepAmount: (e) ->
41
+ if e?.altKey then @smallStep
42
+ else if e?.shiftKey then @largeStep
43
+ else @step
44
+
45
+ increment: (amount) ->
46
+ return if @disabled or @readOnly
47
+ @value = @_clamp(@_roundToStep(+@value + amount))
48
+ @emit 'input', @value
49
+
50
+ decrement: (amount) ->
51
+ return if @disabled or @readOnly
52
+ @value = @_clamp(@_roundToStep(+@value - amount))
53
+ @emit 'input', @value
54
+
55
+ _startRepeat: (dir, e) ->
56
+ amount = @_stepAmount(e)
57
+ tick = => if dir > 0 then @increment(amount) else @decrement(amount)
58
+ tick()
59
+ _timer = setTimeout =>
60
+ _interval = setInterval tick, TICK_DELAY
61
+ , START_DELAY
62
+
63
+ _stopRepeat: ->
64
+ clearTimeout _timer if _timer
65
+ clearInterval _interval if _interval
66
+ _timer = null
67
+ _interval = null
68
+ @emit 'change', @value
69
+
70
+ _onIncDown: (e) ->
71
+ return if @disabled or @readOnly or e.button isnt 0
72
+ e.preventDefault()
73
+ @_input?.focus()
74
+ @_startRepeat 1, e
75
+ onUp = =>
76
+ @_stopRepeat()
77
+ document.removeEventListener 'pointerup', onUp
78
+ document.addEventListener 'pointerup', onUp
79
+
80
+ _onDecDown: (e) ->
81
+ return if @disabled or @readOnly or e.button isnt 0
82
+ e.preventDefault()
83
+ @_input?.focus()
84
+ @_startRepeat -1, e
85
+ onUp = =>
86
+ @_stopRepeat()
87
+ document.removeEventListener 'pointerup', onUp
88
+ document.addEventListener 'pointerup', onUp
89
+
90
+ onKeydown: (e) ->
91
+ return if @disabled or @readOnly
92
+ amount = @_stepAmount(e)
93
+ switch e.key
94
+ when 'ArrowUp'
95
+ e.preventDefault()
96
+ @increment(amount)
97
+ @emit 'change', @value
98
+ when 'ArrowDown'
99
+ e.preventDefault()
100
+ @decrement(amount)
101
+ @emit 'change', @value
102
+ when 'PageUp'
103
+ e.preventDefault()
104
+ @increment(@largeStep)
105
+ @emit 'change', @value
106
+ when 'PageDown'
107
+ e.preventDefault()
108
+ @decrement(@largeStep)
109
+ @emit 'change', @value
110
+ when 'Home'
111
+ if @min?
112
+ e.preventDefault()
113
+ @value = @min
114
+ @emit 'change', @value
115
+ when 'End'
116
+ if @max?
117
+ e.preventDefault()
118
+ @value = @max
119
+ @emit 'change', @value
120
+
121
+ _onBlur: ->
122
+ val = parseFloat @_input?.value
123
+ unless isNaN(val)
124
+ @value = @_clamp(@_roundToStep(val))
125
+ @emit 'change', @value
126
+
127
+ _ready := false
128
+
129
+ mounted: -> _ready = true
130
+
131
+ ~>
132
+ return unless _ready
133
+ @_input?.value = String(@value)
134
+
135
+ beforeUnmount: -> @_stopRepeat()
136
+
137
+ render
138
+ div role: "group", $disabled: @disabled?!, $readonly: @readOnly?!
139
+ button aria-label: "Decrease", tabindex: "-1"
140
+ $decrement: true
141
+ aria-controls: _id
142
+ disabled: @disabled or (@min? and @value <= @min)
143
+ @pointerdown: @_onDecDown
144
+
145
+ input ref: "_input", id: _id, type: "text", inputmode: "numeric"
146
+ name: @name?!
147
+ aria-roledescription: "Number field"
148
+ aria-valuenow: @value
149
+ aria-valuemin: @min?!
150
+ aria-valuemax: @max?!
151
+ aria-disabled: @disabled?!
152
+ aria-readonly: @readOnly?!
153
+ disabled: @disabled
154
+ readonly: @readOnly
155
+ @keydown: @onKeydown
156
+ @blur: @_onBlur
157
+
158
+ button aria-label: "Increase", tabindex: "-1"
159
+ $increment: true
160
+ aria-controls: _id
161
+ disabled: @disabled or (@max? and @value >= @max)
162
+ @pointerdown: @_onIncDown
@@ -0,0 +1,89 @@
1
+ # OTPField — accessible headless one-time password input
2
+ #
3
+ # Multi-digit code input with auto-advance, backspace navigation, and
4
+ # paste support. Each digit gets its own input box. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # OTPField length: 6, value <=> code, @complete: handleVerify
8
+
9
+ export OTPField = component
10
+ @length := 6
11
+ @value := ''
12
+ @disabled := false
13
+ @mask := false
14
+
15
+ _id =! "otp-#{Math.random().toString(36).slice(2, 8)}"
16
+
17
+ _getInputs: ->
18
+ return [] unless @_root
19
+ Array.from(@_root.querySelectorAll('input'))
20
+
21
+ _focusAt: (idx) ->
22
+ inputs = @_getInputs()
23
+ inputs[idx]?.focus()
24
+ inputs[idx]?.select()
25
+
26
+ _updateValue: ->
27
+ inputs = @_getInputs()
28
+ digits = inputs.map (el) -> el.value
29
+ @value = digits.join('')
30
+ @emit 'input', @value
31
+ if @value.length is @length and digits.every (d) -> d.length is 1
32
+ @emit 'complete', @value
33
+
34
+ _onInput: (e, idx) ->
35
+ ch = e.target.value.slice(-1)
36
+ e.target.value = ch
37
+ @_updateValue()
38
+ @_focusAt(idx + 1) if ch and idx < @length - 1
39
+
40
+ _onKeydown: (e, idx) ->
41
+ switch e.key
42
+ when 'Backspace'
43
+ if not e.target.value and idx > 0
44
+ @_focusAt(idx - 1)
45
+ inputs = @_getInputs()
46
+ inputs[idx - 1]?.value = ''
47
+ @_updateValue()
48
+ when 'ArrowLeft'
49
+ e.preventDefault()
50
+ @_focusAt(idx - 1) if idx > 0
51
+ when 'ArrowRight'
52
+ e.preventDefault()
53
+ @_focusAt(idx + 1) if idx < @length - 1
54
+ when 'Home'
55
+ e.preventDefault()
56
+ @_focusAt(0)
57
+ when 'End'
58
+ e.preventDefault()
59
+ @_focusAt(@length - 1)
60
+
61
+ _onPaste: (e) ->
62
+ e.preventDefault()
63
+ text = (e.clipboardData?.getData('text') or '').replace(/\D/g, '').slice(0, @length)
64
+ return unless text
65
+ inputs = @_getInputs()
66
+ for ch, idx in text.split('')
67
+ inputs[idx]?.value = ch
68
+ @_updateValue()
69
+ @_focusAt(Math.min(text.length, @length - 1))
70
+
71
+ _onFocus: (e) -> e.target.select()
72
+
73
+ render
74
+ div ref: "_root", role: "group", aria-label: "One-time password"
75
+ $disabled: @disabled?!
76
+ $complete: (@value.length is @length)?!
77
+ for idx in [0...@length]
78
+ input id: "#{_id}-#{idx}"
79
+ type: if @mask then "password" else "text"
80
+ inputmode: "numeric"
81
+ autocomplete: "one-time-code"
82
+ maxlength: "1"
83
+ aria-label: "Digit #{idx + 1} of #{@length}"
84
+ disabled: @disabled
85
+ $filled: (@value[idx])?!
86
+ @input: (e) => @_onInput(e, idx)
87
+ @keydown: (e) => @_onKeydown(e, idx)
88
+ @paste: @_onPaste
89
+ @focus: @_onFocus
@@ -0,0 +1,143 @@
1
+ # Popover — accessible headless popover with anchor positioning
2
+ #
3
+ # Positions itself relative to the trigger. Dismisses on Escape or click outside.
4
+ # Exposes $open, $placement on content. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Popover placement: "bottom-start"
8
+ # button $trigger: true, "Click me"
9
+ # div $content: true
10
+ # p "Popover content"
11
+
12
+ export Popover = component
13
+ @placement := 'bottom-start'
14
+ @offset := 4
15
+ @disabled := false
16
+ @openOnHover := false
17
+ @hoverDelay := 300
18
+ @hoverCloseDelay := 200
19
+
20
+ open := false
21
+ _ready := false
22
+ _hoverTimer := null
23
+ _hoverCloseTimer := null
24
+ _id =! "pop-#{Math.random().toString(36).slice(2, 8)}"
25
+
26
+ mounted: ->
27
+ _ready = true
28
+ trigger = @_content?.querySelector('[data-trigger]')
29
+ if trigger
30
+ trigger.setAttribute 'aria-expanded', false
31
+ trigger.setAttribute 'aria-haspopup', 'dialog'
32
+ trigger.addEventListener 'click', => @toggle()
33
+ trigger.addEventListener 'keydown', (e) =>
34
+ if e.key in ['Enter', ' ', 'ArrowDown']
35
+ e.preventDefault()
36
+ @toggle()
37
+ if @openOnHover
38
+ trigger.addEventListener 'mouseenter', =>
39
+ clearTimeout _hoverCloseTimer if _hoverCloseTimer
40
+ _hoverTimer = setTimeout (=> @openPopover()), @hoverDelay
41
+ trigger.addEventListener 'mouseleave', =>
42
+ clearTimeout _hoverTimer if _hoverTimer
43
+ _hoverCloseTimer = setTimeout (=> @close()), @hoverCloseDelay
44
+
45
+ toggle: ->
46
+ return if @disabled
47
+ if open then @close() else @openPopover()
48
+
49
+ openPopover: ->
50
+ open = true
51
+ setTimeout => @_position(), 0
52
+
53
+ close: ->
54
+ open = false
55
+ @_content?.querySelector('[data-trigger]')?.focus()
56
+
57
+ _position: ->
58
+ trigger = @_content?.querySelector('[data-trigger]')
59
+ floating = @_content?.querySelector('[data-content]')
60
+ return unless trigger and floating
61
+ @_content.style.position = 'relative'
62
+ tr = trigger.getBoundingClientRect()
63
+ cr = @_content.getBoundingClientRect()
64
+ fl = floating.getBoundingClientRect()
65
+ [side, align] = @placement.split('-')
66
+ align ?= 'center'
67
+ gap = @offset
68
+
69
+ x = switch side
70
+ when 'bottom', 'top'
71
+ switch align
72
+ when 'start' then tr.left - cr.left
73
+ when 'end' then tr.right - cr.left - fl.width
74
+ else tr.left - cr.left + (tr.width - fl.width) / 2
75
+ when 'right' then tr.right - cr.left + gap
76
+ when 'left' then tr.left - cr.left - fl.width - gap
77
+
78
+ y = switch side
79
+ when 'bottom' then tr.bottom - cr.top + gap
80
+ when 'top' then tr.top - cr.top - fl.height - gap
81
+ when 'left', 'right'
82
+ switch align
83
+ when 'start' then tr.top - cr.top
84
+ when 'end' then tr.bottom - cr.top - fl.height
85
+ else tr.top - cr.top + (tr.height - fl.height) / 2
86
+
87
+ if side is 'bottom' and tr.bottom + gap + fl.height > window.innerHeight
88
+ y = tr.top - cr.top - fl.height - gap
89
+ if side is 'top' and tr.top - fl.height - gap < 0
90
+ y = tr.bottom - cr.top + gap
91
+ if side is 'right' and tr.right + gap + fl.width > window.innerWidth
92
+ x = tr.left - cr.left - fl.width - gap
93
+ if side is 'left' and tr.left - fl.width - gap < 0
94
+ x = tr.right - cr.left + gap
95
+
96
+ x = Math.max(4 - cr.left, Math.min(x, window.innerWidth - cr.left - fl.width - 4))
97
+
98
+ floating.style.position = 'absolute'
99
+ floating.style.left = "#{x}px"
100
+ floating.style.top = "#{y}px"
101
+ floating.style.zIndex = '50'
102
+
103
+ ~>
104
+ return unless _ready
105
+ trigger = @_content?.querySelector('[data-trigger]')
106
+ floating = @_content?.querySelector('[data-content]')
107
+ if trigger
108
+ trigger.setAttribute 'aria-expanded', !!open
109
+ if floating
110
+ floating.hidden = not open
111
+ if open
112
+ floating.setAttribute 'data-open', ''
113
+ floating.setAttribute 'data-placement', @placement
114
+ heading = floating.querySelector('h1,h2,h3,h4,h5,h6')
115
+ if heading
116
+ heading.id ?= "#{_id}-title"
117
+ floating.setAttribute 'aria-labelledby', heading.id
118
+ desc = floating.querySelector('p')
119
+ if desc
120
+ desc.id ?= "#{_id}-desc"
121
+ floating.setAttribute 'aria-describedby', desc.id
122
+ else
123
+ floating.removeAttribute 'data-open'
124
+
125
+ ~>
126
+ return unless _ready
127
+ if open
128
+ onDown = (e) =>
129
+ trigger = @_content?.querySelector('[data-trigger]')
130
+ floating = @_content?.querySelector('[data-content]')
131
+ unless trigger?.contains(e.target) or floating?.contains(e.target)
132
+ @close()
133
+ document.addEventListener 'mousedown', onDown
134
+ return -> document.removeEventListener 'mousedown', onDown
135
+
136
+ onKeydown: (e) ->
137
+ if e.key is 'Escape' and open
138
+ e.preventDefault()
139
+ @close()
140
+
141
+ render
142
+ div ref: "_content"
143
+ slot
@@ -0,0 +1,73 @@
1
+ # PreviewCard — accessible headless hover preview card
2
+ #
3
+ # Shows a floating card on hover/focus of a trigger element. Dismisses
4
+ # on mouse leave or blur. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # PreviewCard delay: 400
8
+ # a $trigger: true, href: "/user/42", "View Profile"
9
+ # div $content: true
10
+ # p "User details here..."
11
+
12
+ export PreviewCard = component
13
+ @delay := 400
14
+ @closeDelay := 200
15
+
16
+ open := false
17
+ _ready := false
18
+ _openTimer := null
19
+ _closeTimer := null
20
+
21
+ beforeUnmount: ->
22
+ clearTimeout _openTimer if _openTimer
23
+ clearTimeout _closeTimer if _closeTimer
24
+
25
+ mounted: ->
26
+ _ready = true
27
+ trigger = @_root?.querySelector('[data-trigger]')
28
+ return unless trigger
29
+ trigger.addEventListener 'mouseenter', =>
30
+ clearTimeout _closeTimer if _closeTimer
31
+ _openTimer = setTimeout (=> open = true; @_position()), @delay
32
+ trigger.addEventListener 'mouseleave', =>
33
+ clearTimeout _openTimer if _openTimer
34
+ _closeTimer = setTimeout (=> open = false), @closeDelay
35
+ trigger.addEventListener 'focus', =>
36
+ clearTimeout _closeTimer if _closeTimer
37
+ _openTimer = setTimeout (=> open = true; @_position()), @delay
38
+ trigger.addEventListener 'blur', =>
39
+ clearTimeout _openTimer if _openTimer
40
+ _closeTimer = setTimeout (=> open = false), @closeDelay
41
+
42
+ _position: ->
43
+ trigger = @_root?.querySelector('[data-trigger]')
44
+ floating = @_root?.querySelector('[data-content]')
45
+ return unless trigger and floating
46
+ @_root.style.position = 'relative'
47
+ tr = trigger.getBoundingClientRect()
48
+ cr = @_root.getBoundingClientRect()
49
+ floating.style.position = 'absolute'
50
+ floating.style.left = "0px"
51
+ floating.style.top = "#{tr.bottom - cr.top + 4}px"
52
+ floating.style.zIndex = '50'
53
+
54
+ ~>
55
+ return unless _ready
56
+ floating = @_root?.querySelector('[data-content]')
57
+ return unless floating
58
+ floating.hidden = not open
59
+ if open
60
+ floating.setAttribute 'data-open', ''
61
+ onEnter = => clearTimeout _closeTimer if _closeTimer
62
+ onLeave = => _closeTimer = setTimeout (=> open = false), @closeDelay
63
+ floating.addEventListener 'mouseenter', onEnter
64
+ floating.addEventListener 'mouseleave', onLeave
65
+ return ->
66
+ floating.removeEventListener 'mouseenter', onEnter
67
+ floating.removeEventListener 'mouseleave', onLeave
68
+ else
69
+ floating.removeAttribute 'data-open'
70
+
71
+ render
72
+ div ref: "_root"
73
+ slot
@@ -0,0 +1,25 @@
1
+ # Progress — accessible headless progress bar
2
+ #
3
+ # Exposes progress value as CSS custom property for styling.
4
+ # Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Progress value: 0.65
8
+ # Progress value: 42, max: 100
9
+
10
+ export Progress = component
11
+ @value := 0
12
+ @max := 1
13
+ @label := null
14
+
15
+ percent ~= Math.min(100, Math.max(0, (@value / @max) * 100))
16
+
17
+ render
18
+ div role: "progressbar"
19
+ aria-valuenow: @value
20
+ aria-valuemin: 0
21
+ aria-valuemax: @max
22
+ aria-label: @label?!
23
+ style: "--progress-value: #{@value}; --progress-percent: #{percent}%"
24
+ $complete: (percent >= 100)?!
25
+ slot
@@ -0,0 +1,67 @@
1
+ # RadioGroup — accessible headless radio group
2
+ #
3
+ # Exactly one option can be selected. Arrow keys move focus and selection.
4
+ # Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # RadioGroup value <=> size
8
+ # div $value: "sm", "Small"
9
+ # div $value: "md", "Medium"
10
+ # div $value: "lg", "Large"
11
+
12
+ export RadioGroup = component
13
+ @value := null
14
+ @disabled := false
15
+ @orientation := 'vertical'
16
+ @name := ''
17
+
18
+ _options ~=
19
+ return [] unless @_slot
20
+ Array.from(@_slot.querySelectorAll('[data-value]') or [])
21
+
22
+ _select: (val) ->
23
+ return if @disabled
24
+ @value = val
25
+ @emit 'change', @value
26
+
27
+ onKeydown: (e) ->
28
+ radios = @_root?.querySelectorAll('[role="radio"]')
29
+ return unless radios?.length
30
+ focused = Array.from(radios).indexOf(document.activeElement)
31
+ return if focused < 0
32
+ len = radios.length
33
+ next = focused
34
+ switch e.key
35
+ when 'ArrowRight', 'ArrowDown'
36
+ e.preventDefault()
37
+ next = (focused + 1) %% len
38
+ when 'ArrowLeft', 'ArrowUp'
39
+ e.preventDefault()
40
+ next = (focused - 1) %% len
41
+ when 'Home'
42
+ e.preventDefault()
43
+ next = 0
44
+ when 'End'
45
+ e.preventDefault()
46
+ next = len - 1
47
+ else return
48
+ radios[next]?.focus()
49
+ @_select(_options[next]?.dataset.value)
50
+
51
+ render
52
+ div ref: "_root", role: "radiogroup", aria-orientation: @orientation
53
+ $orientation: @orientation
54
+ $disabled: @disabled?!
55
+
56
+ . ref: "_slot", style: "display:none"
57
+ slot
58
+
59
+ for opt, idx in _options
60
+ button role: "radio"
61
+ tabindex: (if (opt.dataset.value is @value) or (@value is null and idx is 0) then "0" else "-1")
62
+ aria-checked: opt.dataset.value is @value
63
+ $checked: (opt.dataset.value is @value)?!
64
+ $disabled: @disabled?!
65
+ $value: opt.dataset.value
66
+ @click: (=> @_select(opt.dataset.value))
67
+ = opt.textContent
@@ -0,0 +1,145 @@
1
+ # ScrollArea — accessible headless custom scrollbar
2
+ #
3
+ # Renders custom scrollbar thumb that tracks scroll position. Thumb is
4
+ # draggable and the track is clickable. Auto-hides when not scrolling.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # ScrollArea
9
+ # div "Long scrollable content..."
10
+
11
+ MIN_THUMB = 20
12
+
13
+ export ScrollArea = component
14
+ @orientation := 'vertical'
15
+
16
+ hovering := false
17
+ scrolling := false
18
+ _scrollTimer = null
19
+ _dragStart = 0
20
+ _dragScrollStart = 0
21
+ _dragging = false
22
+ _ready := false
23
+
24
+ _updateThumb: ->
25
+ vp = @_viewport
26
+ sb = @_scrollbar
27
+ th = @_thumb
28
+ return unless vp and sb and th
29
+ vert = @orientation is 'vertical'
30
+
31
+ vpSize = if vert then vp.clientHeight else vp.clientWidth
32
+ scSize = if vert then vp.scrollHeight else vp.scrollWidth
33
+ sbSize = if vert then sb.clientHeight else sb.clientWidth
34
+ scrollPos = if vert then vp.scrollTop else vp.scrollLeft
35
+
36
+ ratio = vpSize / (scSize or 1)
37
+ if ratio >= 1
38
+ th.style.display = 'none'
39
+ return
40
+ th.style.display = ''
41
+
42
+ thumbPx = Math.max(MIN_THUMB, sbSize * ratio)
43
+ scrollRange = scSize - vpSize
44
+ maxOffset = sbSize - thumbPx
45
+ posPx = if scrollRange > 0 then Math.min(maxOffset, Math.max(0, (scrollPos / scrollRange) * maxOffset)) else 0
46
+
47
+ if vert
48
+ th.style.height = "#{thumbPx}px"
49
+ th.style.transform = "translate3d(0,#{posPx}px,0)"
50
+ else
51
+ th.style.width = "#{thumbPx}px"
52
+ th.style.transform = "translate3d(#{posPx}px,0,0)"
53
+
54
+ _onScroll: ->
55
+ scrolling = true
56
+ clearTimeout _scrollTimer if _scrollTimer
57
+ _scrollTimer = setTimeout (-> scrolling = false), 800
58
+ @_updateThumb()
59
+
60
+ _onTrackClick: (e) ->
61
+ return if e.target is @_thumb
62
+ vp = @_viewport
63
+ sb = @_scrollbar
64
+ th = @_thumb
65
+ return unless vp and sb and th
66
+ vert = @orientation is 'vertical'
67
+ rect = sb.getBoundingClientRect()
68
+ thumbPx = if vert then th.offsetHeight else th.offsetWidth
69
+
70
+ if vert
71
+ clickPos = e.clientY - rect.top - thumbPx / 2
72
+ maxOffset = rect.height - thumbPx
73
+ ratio = Math.max(0, Math.min(1, clickPos / maxOffset))
74
+ vp.scrollTop = ratio * (vp.scrollHeight - vp.clientHeight)
75
+ else
76
+ clickPos = e.clientX - rect.left - thumbPx / 2
77
+ maxOffset = rect.width - thumbPx
78
+ ratio = Math.max(0, Math.min(1, clickPos / maxOffset))
79
+ vp.scrollLeft = ratio * (vp.scrollWidth - vp.clientWidth)
80
+
81
+ _onThumbDown: (e) ->
82
+ return if e.button isnt 0
83
+ e.preventDefault()
84
+ e.stopPropagation()
85
+ _dragging = true
86
+ if @orientation is 'vertical'
87
+ _dragStart = e.clientY
88
+ _dragScrollStart = @_viewport.scrollTop
89
+ else
90
+ _dragStart = e.clientX
91
+ _dragScrollStart = @_viewport.scrollLeft
92
+ @_thumb.setPointerCapture e.pointerId
93
+
94
+ _onThumbMove: (e) ->
95
+ return unless _dragging
96
+ vp = @_viewport
97
+ sb = @_scrollbar
98
+ th = @_thumb
99
+ return unless vp and sb and th
100
+ vert = @orientation is 'vertical'
101
+ thumbPx = if vert then th.offsetHeight else th.offsetWidth
102
+ sbSize = if vert then sb.clientHeight else sb.clientWidth
103
+ maxOffset = sbSize - thumbPx
104
+ return if maxOffset <= 0
105
+
106
+ delta = if vert then e.clientY - _dragStart else e.clientX - _dragStart
107
+ scrollRange = if vert then vp.scrollHeight - vp.clientHeight else vp.scrollWidth - vp.clientWidth
108
+ newPos = _dragScrollStart + (delta / maxOffset) * scrollRange
109
+
110
+ if vert then vp.scrollTop = newPos else vp.scrollLeft = newPos
111
+
112
+ _onThumbUp: (e) ->
113
+ _dragging = false
114
+ @_thumb.releasePointerCapture e.pointerId
115
+
116
+ mounted: ->
117
+ _ready = true
118
+ requestAnimationFrame => @_updateThumb()
119
+ if @_viewport
120
+ @_resizeObs = new ResizeObserver => @_updateThumb()
121
+ @_resizeObs.observe @_viewport
122
+ @_resizeObs.observe @_viewport.firstElementChild if @_viewport.firstElementChild
123
+
124
+ beforeUnmount: ->
125
+ @_resizeObs?.disconnect()
126
+
127
+ render
128
+ div $orientation: @orientation
129
+ $hovering: hovering?!
130
+ $scrolling: scrolling?!
131
+ $dragging: _dragging?!
132
+ @mouseenter: (=> hovering = true)
133
+ @mouseleave: (=> hovering = false)
134
+
135
+ div ref: "_viewport", $viewport: true
136
+ style: "overflow:scroll;scrollbar-width:none"
137
+ @scroll: @_onScroll
138
+ slot
139
+
140
+ div ref: "_scrollbar", $scrollbar: true
141
+ @click: @_onTrackClick
142
+ div ref: "_thumb", $thumb: true
143
+ @pointerdown: @_onThumbDown
144
+ @pointermove: @_onThumbMove
145
+ @pointerup: @_onThumbUp