@rip-lang/ui 0.3.20 → 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.
- package/README.md +442 -572
- package/accordion.rip +113 -0
- package/alert-dialog.rip +96 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/badge.rip +15 -0
- package/breadcrumb.rip +46 -0
- package/button-group.rip +26 -0
- package/button.rip +23 -0
- package/card.rip +25 -0
- package/carousel.rip +110 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/collapsible.rip +50 -0
- package/combobox.rip +155 -0
- package/context-menu.rip +105 -0
- package/date-picker.rip +214 -0
- package/dialog.rip +107 -0
- package/drawer.rip +79 -0
- package/editable-value.rip +80 -0
- package/field.rip +53 -0
- package/fieldset.rip +22 -0
- package/form.rip +39 -0
- package/grid.rip +901 -0
- package/index.rip +16 -0
- package/input-group.rip +28 -0
- package/input.rip +36 -0
- package/label.rip +16 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/native-select.rip +32 -0
- package/nav-menu.rip +129 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +18 -27
- package/pagination.rip +123 -0
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/resizable.rip +123 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/skeleton.rip +22 -0
- package/slider.rip +165 -0
- package/spinner.rip +17 -0
- package/table.rip +27 -0
- package/tabs.rip +124 -0
- package/textarea.rip +48 -0
- package/toast.rip +87 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/dist/rip-ui.min.js +0 -522
- package/dist/rip-ui.min.js.br +0 -0
- package/serve.rip +0 -92
- package/ui.rip +0 -964
package/preview-card.rip
ADDED
|
@@ -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
|
package/progress.rip
ADDED
|
@@ -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
|
package/radio-group.rip
ADDED
|
@@ -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
|
package/resizable.rip
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Resizable — accessible headless resizable panels
|
|
2
|
+
#
|
|
3
|
+
# Container with draggable handles between panels for resizing.
|
|
4
|
+
# Panel sizes are stored as percentages and exposed via CSS
|
|
5
|
+
# custom properties. Place [data-handle] elements between [data-panel]
|
|
6
|
+
# elements. Ships zero CSS.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# Resizable
|
|
10
|
+
# div $panel: true
|
|
11
|
+
# p "Left panel"
|
|
12
|
+
# div $handle: true
|
|
13
|
+
# div $panel: true
|
|
14
|
+
# p "Right panel"
|
|
15
|
+
#
|
|
16
|
+
# Resizable orientation: "vertical", minSize: 20
|
|
17
|
+
# div $panel: true, "Top"
|
|
18
|
+
# div $handle: true
|
|
19
|
+
# div $panel: true, "Bottom"
|
|
20
|
+
|
|
21
|
+
export Resizable = component
|
|
22
|
+
@orientation := 'horizontal'
|
|
23
|
+
@minSize := 10
|
|
24
|
+
@maxSize := 90
|
|
25
|
+
|
|
26
|
+
_ready := false
|
|
27
|
+
_dragging = null
|
|
28
|
+
_startPos = 0
|
|
29
|
+
_startSizes = []
|
|
30
|
+
sizes := []
|
|
31
|
+
|
|
32
|
+
_panels: ->
|
|
33
|
+
return [] unless @_root
|
|
34
|
+
Array.from(@_root.querySelectorAll(':scope > [data-panel]') or [])
|
|
35
|
+
|
|
36
|
+
_handles: ->
|
|
37
|
+
return [] unless @_root
|
|
38
|
+
Array.from(@_root.querySelectorAll(':scope > [data-handle]') or [])
|
|
39
|
+
|
|
40
|
+
mounted: ->
|
|
41
|
+
_ready = true
|
|
42
|
+
panels = @_panels()
|
|
43
|
+
count = panels.length
|
|
44
|
+
if count and not sizes.length
|
|
45
|
+
even = 100 / count
|
|
46
|
+
sizes = Array.from {length: count}, -> even
|
|
47
|
+
|
|
48
|
+
@_handles().forEach (handle, idx) =>
|
|
49
|
+
handle.setAttribute 'role', 'separator'
|
|
50
|
+
handle.setAttribute 'tabindex', '0'
|
|
51
|
+
handle.addEventListener 'pointerdown', (e) => @_onPointerDown(idx, e)
|
|
52
|
+
handle.addEventListener 'keydown', (e) => @_onKeydown(idx, e)
|
|
53
|
+
|
|
54
|
+
_getPos: (e) ->
|
|
55
|
+
if @orientation is 'horizontal' then e.clientX else e.clientY
|
|
56
|
+
|
|
57
|
+
_getContainerSize: ->
|
|
58
|
+
rect = @_root?.getBoundingClientRect()
|
|
59
|
+
return 0 unless rect
|
|
60
|
+
if @orientation is 'horizontal' then rect.width else rect.height
|
|
61
|
+
|
|
62
|
+
_onPointerDown: (handleIdx, e) ->
|
|
63
|
+
e.preventDefault()
|
|
64
|
+
_dragging = handleIdx
|
|
65
|
+
_startPos = @_getPos(e)
|
|
66
|
+
_startSizes = [...sizes]
|
|
67
|
+
e.target.setPointerCapture(e.pointerId)
|
|
68
|
+
e.target.toggleAttribute 'data-dragging', true
|
|
69
|
+
|
|
70
|
+
_onPointerMove: (e) ->
|
|
71
|
+
return unless _dragging?
|
|
72
|
+
total = @_getContainerSize()
|
|
73
|
+
return unless total
|
|
74
|
+
delta = @_getPos(e) - _startPos
|
|
75
|
+
pctDelta = (delta / total) * 100
|
|
76
|
+
@_applyResize(_dragging, _startSizes[_dragging] + pctDelta, _startSizes[_dragging + 1] - pctDelta)
|
|
77
|
+
|
|
78
|
+
_onPointerUp: (e) ->
|
|
79
|
+
return unless _dragging?
|
|
80
|
+
handle = @_handles()[_dragging]
|
|
81
|
+
handle?.removeAttribute 'data-dragging'
|
|
82
|
+
_dragging = null
|
|
83
|
+
@emit 'resize', sizes
|
|
84
|
+
|
|
85
|
+
_applyResize: (idx, newLeft, newRight) ->
|
|
86
|
+
newLeft = Math.max(@minSize, Math.min(@maxSize, newLeft))
|
|
87
|
+
newRight = Math.max(@minSize, Math.min(@maxSize, newRight))
|
|
88
|
+
combined = sizes[idx] + sizes[idx + 1]
|
|
89
|
+
newRight = combined - newLeft
|
|
90
|
+
return if newRight < @minSize or newRight > @maxSize
|
|
91
|
+
updated = [...sizes]
|
|
92
|
+
updated[idx] = newLeft
|
|
93
|
+
updated[idx + 1] = newRight
|
|
94
|
+
sizes = updated
|
|
95
|
+
|
|
96
|
+
_onKeydown: (handleIdx, e) ->
|
|
97
|
+
step = 10
|
|
98
|
+
horiz = @orientation is 'horizontal'
|
|
99
|
+
delta = switch e.key
|
|
100
|
+
when (if horiz then 'ArrowRight' else 'ArrowDown') then step
|
|
101
|
+
when (if horiz then 'ArrowLeft' else 'ArrowUp') then -step
|
|
102
|
+
else null
|
|
103
|
+
return unless delta?
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
@_applyResize(handleIdx, sizes[handleIdx] + delta, sizes[handleIdx + 1] - delta)
|
|
106
|
+
@emit 'resize', sizes
|
|
107
|
+
|
|
108
|
+
~>
|
|
109
|
+
return unless _ready
|
|
110
|
+
@_panels().forEach (el, idx) =>
|
|
111
|
+
pct = sizes[idx] or 0
|
|
112
|
+
el.style.setProperty '--panel-size', "#{pct}%"
|
|
113
|
+
el.style.flexBasis = "#{pct}%"
|
|
114
|
+
@_handles().forEach (handle, idx) =>
|
|
115
|
+
handle.setAttribute 'aria-valuenow', Math.round(sizes[idx] or 0)
|
|
116
|
+
handle.setAttribute 'aria-orientation', @orientation
|
|
117
|
+
|
|
118
|
+
render
|
|
119
|
+
div ref: "_root", $orientation: @orientation
|
|
120
|
+
@pointermove: @_onPointerMove
|
|
121
|
+
@pointerup: @_onPointerUp
|
|
122
|
+
style: "display:flex; flex-direction:#{if @orientation is 'horizontal' then 'row' else 'column'}"
|
|
123
|
+
slot
|
package/scroll-area.rip
ADDED
|
@@ -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
|
package/select.rip
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# Select — accessible headless select widget
|
|
2
|
+
#
|
|
3
|
+
# Keyboard: ArrowDown/Up to navigate, Enter/Space to select, Escape to close,
|
|
4
|
+
# Home/End for first/last, typeahead to jump by character.
|
|
5
|
+
#
|
|
6
|
+
# Exposes $open and $placeholder on button, $highlighted and $selected on options.
|
|
7
|
+
# Ships zero CSS — style entirely via attribute selectors in your stylesheet.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# Select value <=> selectedValue, @change: (=> handle(event.detail))
|
|
11
|
+
# option value: "a", "Option A"
|
|
12
|
+
# option value: "b", "Option B"
|
|
13
|
+
|
|
14
|
+
export Select = component
|
|
15
|
+
@value := null
|
|
16
|
+
@placeholder := 'Select...'
|
|
17
|
+
@disabled := false
|
|
18
|
+
|
|
19
|
+
open := false
|
|
20
|
+
highlightedIndex := -1
|
|
21
|
+
typeaheadBuffer := ''
|
|
22
|
+
typeaheadTimer := null
|
|
23
|
+
_listId =! "sel-#{Math.random().toString(36).slice(2, 8)}"
|
|
24
|
+
|
|
25
|
+
getOpt: (o) -> o.dataset.value ?? o.value
|
|
26
|
+
|
|
27
|
+
options ~=
|
|
28
|
+
return [] unless @_slot
|
|
29
|
+
Array.from(@_slot.querySelectorAll('[data-value], option[value]') or [])
|
|
30
|
+
|
|
31
|
+
selectedLabel ~=
|
|
32
|
+
if @value?
|
|
33
|
+
el = options.find (o) -> @getOpt(o) is String(@value)
|
|
34
|
+
el?.textContent?.trim() or String(@value)
|
|
35
|
+
else
|
|
36
|
+
@placeholder
|
|
37
|
+
|
|
38
|
+
toggle: ->
|
|
39
|
+
return if @disabled
|
|
40
|
+
if open then @close() else @openMenu()
|
|
41
|
+
|
|
42
|
+
openMenu: ->
|
|
43
|
+
open = true
|
|
44
|
+
highlightedIndex = Math.max(0, options.findIndex (o) -> @getOpt(o) is String(@value))
|
|
45
|
+
requestAnimationFrame =>
|
|
46
|
+
@_position()
|
|
47
|
+
@_focusHighlighted()
|
|
48
|
+
|
|
49
|
+
close: ->
|
|
50
|
+
open = false
|
|
51
|
+
highlightedIndex = -1
|
|
52
|
+
@_trigger?.focus()
|
|
53
|
+
|
|
54
|
+
isDisabled: (opt) -> opt?.hasAttribute?('data-disabled') or opt?.disabled
|
|
55
|
+
|
|
56
|
+
selectIndex: (idx) ->
|
|
57
|
+
opt = options[idx]
|
|
58
|
+
return unless opt
|
|
59
|
+
return if @isDisabled(opt)
|
|
60
|
+
@value = @getOpt(opt)
|
|
61
|
+
@emit 'change', @value
|
|
62
|
+
@close()
|
|
63
|
+
|
|
64
|
+
onTriggerKeydown: (e) ->
|
|
65
|
+
return if @disabled
|
|
66
|
+
switch e.key
|
|
67
|
+
when 'ArrowDown', 'ArrowUp', 'Enter', ' '
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
@openMenu()
|
|
70
|
+
when 'Escape'
|
|
71
|
+
e.preventDefault()
|
|
72
|
+
@close() if open
|
|
73
|
+
|
|
74
|
+
_nextEnabled: (from, dir) ->
|
|
75
|
+
len = options.length
|
|
76
|
+
i = from
|
|
77
|
+
loop len
|
|
78
|
+
i = (i + dir) %% len
|
|
79
|
+
return i unless @isDisabled(options[i])
|
|
80
|
+
from
|
|
81
|
+
|
|
82
|
+
onListKeydown: (e) ->
|
|
83
|
+
len = options.length
|
|
84
|
+
return unless len
|
|
85
|
+
|
|
86
|
+
switch e.key
|
|
87
|
+
when 'ArrowDown'
|
|
88
|
+
e.preventDefault()
|
|
89
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, 1)
|
|
90
|
+
@_focusHighlighted()
|
|
91
|
+
when 'ArrowUp'
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, -1)
|
|
94
|
+
@_focusHighlighted()
|
|
95
|
+
when 'Home'
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
highlightedIndex = 0
|
|
98
|
+
@_focusHighlighted()
|
|
99
|
+
when 'End'
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
highlightedIndex = len - 1
|
|
102
|
+
@_focusHighlighted()
|
|
103
|
+
when 'Enter', ' '
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
@selectIndex(highlightedIndex)
|
|
106
|
+
when 'Escape'
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
@close()
|
|
109
|
+
when 'Tab'
|
|
110
|
+
@close()
|
|
111
|
+
else
|
|
112
|
+
if e.key.length is 1
|
|
113
|
+
@_typeahead(e.key)
|
|
114
|
+
|
|
115
|
+
_typeahead: (char) ->
|
|
116
|
+
clearTimeout typeaheadTimer if typeaheadTimer
|
|
117
|
+
typeaheadBuffer += char.toLowerCase()
|
|
118
|
+
typeaheadTimer = setTimeout (-> typeaheadBuffer = ''), 500
|
|
119
|
+
idx = options.findIndex (o) -> o.textContent.trim().toLowerCase().startsWith(typeaheadBuffer)
|
|
120
|
+
if idx >= 0
|
|
121
|
+
highlightedIndex = idx
|
|
122
|
+
@_focusHighlighted()
|
|
123
|
+
|
|
124
|
+
_focusHighlighted: ->
|
|
125
|
+
opt = options[highlightedIndex]
|
|
126
|
+
opt?.focus()
|
|
127
|
+
|
|
128
|
+
_position: ->
|
|
129
|
+
return unless @_trigger and @_list
|
|
130
|
+
tr = @_trigger.getBoundingClientRect()
|
|
131
|
+
@_list.style.position = 'fixed'
|
|
132
|
+
@_list.style.left = "#{tr.left}px"
|
|
133
|
+
@_list.style.top = "#{tr.bottom + 4}px"
|
|
134
|
+
@_list.style.minWidth = "#{tr.width}px"
|
|
135
|
+
fl = @_list.getBoundingClientRect()
|
|
136
|
+
if fl.bottom > window.innerHeight
|
|
137
|
+
@_list.style.top = "#{tr.top - fl.height - 4}px"
|
|
138
|
+
@_list.style.visibility = 'visible'
|
|
139
|
+
|
|
140
|
+
~>
|
|
141
|
+
if open
|
|
142
|
+
onDown = (e) =>
|
|
143
|
+
unless @_trigger?.contains(e.target) or @_list?.contains(e.target)
|
|
144
|
+
@close()
|
|
145
|
+
document.addEventListener 'mousedown', onDown
|
|
146
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
147
|
+
|
|
148
|
+
render
|
|
149
|
+
.
|
|
150
|
+
|
|
151
|
+
# Button
|
|
152
|
+
button ref: "_trigger", role: "combobox"
|
|
153
|
+
aria-expanded: !!open
|
|
154
|
+
aria-haspopup: "listbox"
|
|
155
|
+
aria-controls: open ? _listId : undefined
|
|
156
|
+
$open: open?!
|
|
157
|
+
$placeholder: (!@value)?!
|
|
158
|
+
$disabled: @disabled?!
|
|
159
|
+
disabled: @disabled
|
|
160
|
+
@click: @toggle
|
|
161
|
+
@keydown: @onTriggerKeydown
|
|
162
|
+
span selectedLabel
|
|
163
|
+
|
|
164
|
+
# Hidden slot for reading option definitions
|
|
165
|
+
. ref: "_slot", style: "display:none"
|
|
166
|
+
slot
|
|
167
|
+
|
|
168
|
+
# Dropdown listbox
|
|
169
|
+
if open
|
|
170
|
+
div ref: "_list", id: _listId, role: "listbox", style: "position:fixed;visibility:hidden"
|
|
171
|
+
$open: true
|
|
172
|
+
@keydown: @onListKeydown
|
|
173
|
+
for opt, idx in options
|
|
174
|
+
div role: "option"
|
|
175
|
+
tabindex: "-1"
|
|
176
|
+
$value: @getOpt(opt)
|
|
177
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
178
|
+
$selected: (@getOpt(opt) is String(@value))?!
|
|
179
|
+
$disabled: @isDisabled(opt)?!
|
|
180
|
+
aria-selected: @getOpt(opt) is String(@value)
|
|
181
|
+
aria-disabled: @isDisabled(opt)?!
|
|
182
|
+
@click: (=> @selectIndex(idx))
|
|
183
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
184
|
+
= opt.textContent
|
package/separator.rip
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Separator — accessible headless visual divider
|
|
2
|
+
#
|
|
3
|
+
# Decorative or semantic separator between content sections.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Separator
|
|
8
|
+
# Separator orientation: "vertical"
|
|
9
|
+
|
|
10
|
+
export Separator = component
|
|
11
|
+
@orientation := 'horizontal'
|
|
12
|
+
@decorative := true
|
|
13
|
+
|
|
14
|
+
render
|
|
15
|
+
div role: (if @decorative then 'none' else 'separator')
|
|
16
|
+
aria-orientation: (if @orientation is 'vertical' then 'vertical' else undefined)
|
|
17
|
+
$orientation: @orientation
|
package/skeleton.rip
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# Skeleton — accessible headless loading placeholder
|
|
2
|
+
#
|
|
3
|
+
# Placeholder element shown while content is loading.
|
|
4
|
+
# Exposes dimensions as CSS custom properties for styling.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Skeleton
|
|
9
|
+
# Skeleton width: "200px", height: "1em"
|
|
10
|
+
# Skeleton circle: true, width: "48px"
|
|
11
|
+
|
|
12
|
+
export Skeleton = component
|
|
13
|
+
@width := null
|
|
14
|
+
@height := null
|
|
15
|
+
@circle := false
|
|
16
|
+
@label := 'Loading'
|
|
17
|
+
|
|
18
|
+
render
|
|
19
|
+
div role: "status", aria-busy: "true", aria-label: @label
|
|
20
|
+
style: "--skeleton-width: #{@width or 'auto'}; --skeleton-height: #{@height or 'auto'}"
|
|
21
|
+
$circle: @circle?!
|
|
22
|
+
slot
|