@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.
- package/README.md +443 -576
- 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 -524
- package/dist/rip-ui.min.js.br +0 -0
- package/serve.rip +0 -92
- package/ui.rip +0 -964
package/nav-menu.rip
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# NavigationMenu — accessible headless site navigation
|
|
2
|
+
#
|
|
3
|
+
# Horizontal navigation with optional dropdown sub-menus. Triggers show
|
|
4
|
+
# content on hover or click. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# NavigationMenu
|
|
8
|
+
# a $link: true, href: "/", "Home"
|
|
9
|
+
# div $trigger: "products"
|
|
10
|
+
# div $panel: true
|
|
11
|
+
# a href: "/ui", "ui"
|
|
12
|
+
# a href: "/tools", "Tools"
|
|
13
|
+
# a $link: true, href: "/about", "About"
|
|
14
|
+
|
|
15
|
+
export NavigationMenu = component
|
|
16
|
+
@orientation := 'horizontal'
|
|
17
|
+
@hoverDelay := 200
|
|
18
|
+
@hoverCloseDelay := 300
|
|
19
|
+
|
|
20
|
+
activePanel := null
|
|
21
|
+
_ready := false
|
|
22
|
+
_hoverTimer := null
|
|
23
|
+
_closeTimer := null
|
|
24
|
+
|
|
25
|
+
_navItems ~=
|
|
26
|
+
return [] unless @_slot
|
|
27
|
+
Array.from(@_slot.children).filter (el) ->
|
|
28
|
+
el.dataset?.link? or el.dataset?.trigger?
|
|
29
|
+
|
|
30
|
+
mounted: -> _ready = true
|
|
31
|
+
|
|
32
|
+
beforeUnmount: ->
|
|
33
|
+
clearTimeout _hoverTimer if _hoverTimer
|
|
34
|
+
clearTimeout _closeTimer if _closeTimer
|
|
35
|
+
|
|
36
|
+
_openPanel: (id) ->
|
|
37
|
+
clearTimeout _closeTimer if _closeTimer
|
|
38
|
+
activePanel = id
|
|
39
|
+
requestAnimationFrame => @_position(id)
|
|
40
|
+
|
|
41
|
+
_closePanel: ->
|
|
42
|
+
activePanel = null
|
|
43
|
+
|
|
44
|
+
_scheduleOpen: (id) ->
|
|
45
|
+
clearTimeout _closeTimer if _closeTimer
|
|
46
|
+
_hoverTimer = setTimeout (=> @_openPanel(id)), @hoverDelay
|
|
47
|
+
|
|
48
|
+
_scheduleClose: ->
|
|
49
|
+
clearTimeout _hoverTimer if _hoverTimer
|
|
50
|
+
_closeTimer = setTimeout (=> @_closePanel()), @hoverCloseDelay
|
|
51
|
+
|
|
52
|
+
_cancelClose: ->
|
|
53
|
+
clearTimeout _closeTimer if _closeTimer
|
|
54
|
+
|
|
55
|
+
_position: (id) ->
|
|
56
|
+
trigger = @_root?.querySelector("[data-nav-trigger=\"#{id}\"]")
|
|
57
|
+
panel = @_root?.querySelector("[data-nav-panel=\"#{id}\"]")
|
|
58
|
+
return unless trigger and panel
|
|
59
|
+
tr = trigger.getBoundingClientRect()
|
|
60
|
+
panel.style.position = 'fixed'
|
|
61
|
+
panel.style.left = "#{tr.left}px"
|
|
62
|
+
panel.style.top = "#{tr.bottom + 2}px"
|
|
63
|
+
|
|
64
|
+
_onKeydown: (e) ->
|
|
65
|
+
navBtns = @_root?.querySelectorAll('[data-nav-trigger], [data-nav-link]')
|
|
66
|
+
return unless navBtns?.length
|
|
67
|
+
focused = Array.from(navBtns).indexOf(document.activeElement)
|
|
68
|
+
return if focused < 0
|
|
69
|
+
len = navBtns.length
|
|
70
|
+
switch e.key
|
|
71
|
+
when 'ArrowRight'
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
navBtns[(focused + 1) %% len]?.focus()
|
|
74
|
+
when 'ArrowLeft'
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
navBtns[(focused - 1) %% len]?.focus()
|
|
77
|
+
when 'ArrowDown'
|
|
78
|
+
triggerId = document.activeElement?.dataset?.navTrigger
|
|
79
|
+
if triggerId
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
@_openPanel(triggerId)
|
|
82
|
+
@_root?.querySelector("[data-nav-panel=\"#{triggerId}\"] a, [data-nav-panel=\"#{triggerId}\"] button")?.focus()
|
|
83
|
+
when 'Escape'
|
|
84
|
+
@_closePanel()
|
|
85
|
+
|
|
86
|
+
~>
|
|
87
|
+
return unless _ready
|
|
88
|
+
if activePanel
|
|
89
|
+
onDown = (e) =>
|
|
90
|
+
unless @_root?.contains(e.target)
|
|
91
|
+
@_closePanel()
|
|
92
|
+
document.addEventListener 'mousedown', onDown
|
|
93
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
94
|
+
|
|
95
|
+
render
|
|
96
|
+
nav ref: "_root", role: "navigation", aria-orientation: @orientation
|
|
97
|
+
$orientation: @orientation
|
|
98
|
+
|
|
99
|
+
. ref: "_slot", style: "display:none"
|
|
100
|
+
slot
|
|
101
|
+
|
|
102
|
+
for navItem, nIdx in _navItems
|
|
103
|
+
if navItem.dataset.link?
|
|
104
|
+
a $nav-link: true, href: navItem.getAttribute('href') or '#', tabindex: "0"
|
|
105
|
+
@keydown: @_onKeydown
|
|
106
|
+
= navItem.textContent
|
|
107
|
+
else if navItem.dataset.trigger?
|
|
108
|
+
. style: "display:inline-block;position:relative"
|
|
109
|
+
button $nav-trigger: navItem.dataset.trigger, tabindex: "0"
|
|
110
|
+
aria-expanded: activePanel is navItem.dataset.trigger
|
|
111
|
+
$open: (activePanel is navItem.dataset.trigger)?!
|
|
112
|
+
@click: (=> if activePanel is navItem.dataset.trigger then @_closePanel() else @_openPanel(navItem.dataset.trigger))
|
|
113
|
+
@mouseenter: (=> @_scheduleOpen(navItem.dataset.trigger))
|
|
114
|
+
@mouseleave: (=> @_scheduleClose())
|
|
115
|
+
@keydown: @_onKeydown
|
|
116
|
+
= navItem.dataset.trigger
|
|
117
|
+
|
|
118
|
+
if activePanel is navItem.dataset.trigger
|
|
119
|
+
div $nav-panel: navItem.dataset.trigger, $open: true
|
|
120
|
+
style: "position:fixed;z-index:50"
|
|
121
|
+
@mouseenter: (=> @_cancelClose())
|
|
122
|
+
@mouseleave: (=> @_scheduleClose())
|
|
123
|
+
for link, lIdx in Array.from(navItem.querySelectorAll('a, [data-link]'))
|
|
124
|
+
a href: link.getAttribute('href') or '#', tabindex: "0"
|
|
125
|
+
@keydown: (e) =>
|
|
126
|
+
if e.key is 'Escape'
|
|
127
|
+
@_closePanel()
|
|
128
|
+
@_root?.querySelector("[data-nav-trigger=\"#{navItem.dataset.trigger}\"]")?.focus()
|
|
129
|
+
= link.textContent
|
package/number-field.rip
ADDED
|
@@ -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
|
package/otp-field.rip
ADDED
|
@@ -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
|
package/package.json
CHANGED
|
@@ -1,22 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/ui",
|
|
3
|
-
"version": "0.3.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.21",
|
|
4
|
+
"description": "Headless, accessible UI components written in Rip — zero CSS, zero dependencies",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "ui.rip",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./ui.rip",
|
|
9
|
-
"./serve": "./serve.rip"
|
|
10
|
-
},
|
|
11
|
-
"scripts": {
|
|
12
|
-
"test": "echo \"Tests coming soon\" && exit 0"
|
|
13
|
-
},
|
|
14
6
|
"keywords": [
|
|
15
7
|
"ui",
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
8
|
+
"headless",
|
|
9
|
+
"accessible",
|
|
10
|
+
"components",
|
|
11
|
+
"widgets",
|
|
12
|
+
"aria",
|
|
13
|
+
"wai-aria",
|
|
14
|
+
"select",
|
|
15
|
+
"dialog",
|
|
16
|
+
"grid",
|
|
17
|
+
"combobox",
|
|
18
|
+
"tabs",
|
|
20
19
|
"rip",
|
|
21
20
|
"rip-lang"
|
|
22
21
|
],
|
|
@@ -31,19 +30,11 @@
|
|
|
31
30
|
},
|
|
32
31
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
33
32
|
"license": "MIT",
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"rip-lang": ">=3.13.76"
|
|
35
|
+
},
|
|
34
36
|
"files": [
|
|
35
|
-
"
|
|
36
|
-
"serve.rip",
|
|
37
|
-
"dist/rip-ui.min.js",
|
|
38
|
-
"dist/rip-ui.min.js.br",
|
|
37
|
+
"*.rip",
|
|
39
38
|
"README.md"
|
|
40
|
-
]
|
|
41
|
-
"peerDependencies": {
|
|
42
|
-
"@rip-lang/api": ">=1.1.10"
|
|
43
|
-
},
|
|
44
|
-
"peerDependenciesMeta": {
|
|
45
|
-
"@rip-lang/api": {
|
|
46
|
-
"optional": true
|
|
47
|
-
}
|
|
48
|
-
}
|
|
39
|
+
]
|
|
49
40
|
}
|
package/pagination.rip
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Pagination — accessible headless page navigation
|
|
2
|
+
#
|
|
3
|
+
# Renders page buttons with prev/next and ellipsis gaps.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Pagination page <=> currentPage, total: 100, perPage: 10
|
|
8
|
+
# Pagination page <=> currentPage, total: 500, perPage: 20, siblingCount: 2
|
|
9
|
+
|
|
10
|
+
export Pagination = component
|
|
11
|
+
@page := 1
|
|
12
|
+
@total := 0
|
|
13
|
+
@perPage := 10
|
|
14
|
+
@siblingCount := 1
|
|
15
|
+
|
|
16
|
+
totalPages ~= Math.max(1, Math.ceil(@total / @perPage))
|
|
17
|
+
_ready := false
|
|
18
|
+
|
|
19
|
+
_range: (start, fin) ->
|
|
20
|
+
len = fin - start + 1
|
|
21
|
+
Array.from {length: len}, (_, i) -> start + i
|
|
22
|
+
|
|
23
|
+
visiblePages ~=
|
|
24
|
+
tp = totalPages
|
|
25
|
+
sibs = @siblingCount
|
|
26
|
+
current = @page
|
|
27
|
+
|
|
28
|
+
totalNumbers = sibs * 2 + 5
|
|
29
|
+
return @_range(1, tp) if tp <= totalNumbers
|
|
30
|
+
|
|
31
|
+
leftSib = Math.max(current - sibs, 1)
|
|
32
|
+
rightSib = Math.min(current + sibs, tp)
|
|
33
|
+
|
|
34
|
+
showLeftDots = leftSib > 2
|
|
35
|
+
showRightDots = rightSib < tp - 1
|
|
36
|
+
|
|
37
|
+
if not showLeftDots and showRightDots
|
|
38
|
+
leftCount = 3 + 2 * sibs
|
|
39
|
+
leftRange = @_range(1, leftCount)
|
|
40
|
+
return [...leftRange, -1, tp]
|
|
41
|
+
|
|
42
|
+
if showLeftDots and not showRightDots
|
|
43
|
+
rightCount = 3 + 2 * sibs
|
|
44
|
+
rightRange = @_range(tp - rightCount + 1, tp)
|
|
45
|
+
return [1, -2, ...rightRange]
|
|
46
|
+
|
|
47
|
+
midRange = @_range(leftSib, rightSib)
|
|
48
|
+
[1, -2, ...midRange, -1, tp]
|
|
49
|
+
|
|
50
|
+
goto: (pg) ->
|
|
51
|
+
pg = Math.max(1, Math.min(pg, totalPages))
|
|
52
|
+
return if pg is @page
|
|
53
|
+
@page = pg
|
|
54
|
+
@emit 'change', @page
|
|
55
|
+
|
|
56
|
+
onKeydown: (e) ->
|
|
57
|
+
switch e.key
|
|
58
|
+
when 'ArrowLeft'
|
|
59
|
+
e.preventDefault()
|
|
60
|
+
@goto(@page - 1)
|
|
61
|
+
when 'ArrowRight'
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
@goto(@page + 1)
|
|
64
|
+
when 'Home'
|
|
65
|
+
e.preventDefault()
|
|
66
|
+
@goto(1)
|
|
67
|
+
when 'End'
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
@goto(totalPages)
|
|
70
|
+
|
|
71
|
+
mounted: ->
|
|
72
|
+
_ready = true
|
|
73
|
+
|
|
74
|
+
_prevPages = null
|
|
75
|
+
|
|
76
|
+
_rebuild: (inner) ->
|
|
77
|
+
frag = document.createDocumentFragment()
|
|
78
|
+
for pg in visiblePages
|
|
79
|
+
if pg < 0
|
|
80
|
+
el = document.createElement 'span'
|
|
81
|
+
el.setAttribute 'data-ellipsis', ''
|
|
82
|
+
el.textContent = '...'
|
|
83
|
+
else
|
|
84
|
+
el = document.createElement 'button'
|
|
85
|
+
el.setAttribute 'aria-label', "Page #{pg}"
|
|
86
|
+
el.setAttribute 'data-page', ''
|
|
87
|
+
el.textContent = "#{pg}"
|
|
88
|
+
el.addEventListener 'click', => @goto(pg)
|
|
89
|
+
frag.appendChild el
|
|
90
|
+
inner.replaceChildren frag
|
|
91
|
+
_prevPages = visiblePages.join ','
|
|
92
|
+
|
|
93
|
+
_syncActive: (inner) ->
|
|
94
|
+
for btn in inner.querySelectorAll('[data-page]')
|
|
95
|
+
pg = parseInt btn.textContent
|
|
96
|
+
if pg is @page
|
|
97
|
+
btn.setAttribute 'aria-current', 'page'
|
|
98
|
+
btn.setAttribute 'data-active', ''
|
|
99
|
+
else
|
|
100
|
+
btn.removeAttribute 'aria-current'
|
|
101
|
+
btn.removeAttribute 'data-active'
|
|
102
|
+
|
|
103
|
+
~>
|
|
104
|
+
return unless _ready
|
|
105
|
+
inner = @_nav?.querySelector('[data-pages]')
|
|
106
|
+
return unless inner
|
|
107
|
+
|
|
108
|
+
key = visiblePages.join ','
|
|
109
|
+
if key isnt _prevPages
|
|
110
|
+
@_rebuild inner
|
|
111
|
+
@_syncActive inner
|
|
112
|
+
|
|
113
|
+
render
|
|
114
|
+
nav ref: "_nav", aria-label: "Pagination", @keydown: @onKeydown
|
|
115
|
+
button $prev: true, aria-label: "Previous page"
|
|
116
|
+
disabled: @page <= 1
|
|
117
|
+
$disabled: (@page <= 1)?!
|
|
118
|
+
@click: (=> @goto(@page - 1))
|
|
119
|
+
. $pages: true
|
|
120
|
+
button $next: true, aria-label: "Next page"
|
|
121
|
+
disabled: @page >= totalPages
|
|
122
|
+
$disabled: (@page >= totalPages)?!
|
|
123
|
+
@click: (=> @goto(@page + 1))
|
package/popover.rip
ADDED
|
@@ -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
|