@rip-lang/ui 0.1.3 → 0.3.1
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 +507 -726
- package/accordion.rip +113 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/button.rip +23 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/combobox.rip +153 -0
- package/context-menu.rip +98 -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 +15 -0
- package/input.rip +35 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/nav-menu.rip +132 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +16 -37
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/slider.rip +165 -0
- package/tabs.rip +124 -0
- package/toast.rip +88 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/renderer.js +0 -397
- package/router.js +0 -325
- package/serve.rip +0 -140
- package/stash.js +0 -413
- package/ui.js +0 -208
- package/vfs.js +0 -215
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,29 +1,21 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/ui",
|
|
3
|
-
"version": "0.1
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.3.1",
|
|
4
|
+
"description": "Headless, accessible UI components written in Rip — zero CSS, zero dependencies",
|
|
5
5
|
"type": "module",
|
|
6
|
-
"main": "ui.js",
|
|
7
|
-
"exports": {
|
|
8
|
-
".": "./ui.js",
|
|
9
|
-
"./stash": "./stash.js",
|
|
10
|
-
"./vfs": "./vfs.js",
|
|
11
|
-
"./router": "./router.js",
|
|
12
|
-
"./renderer": "./renderer.js",
|
|
13
|
-
"./serve": "./serve.rip"
|
|
14
|
-
},
|
|
15
|
-
"scripts": {
|
|
16
|
-
"test": "echo \"Tests coming soon\" && exit 0"
|
|
17
|
-
},
|
|
18
6
|
"keywords": [
|
|
19
7
|
"ui",
|
|
20
|
-
"
|
|
21
|
-
"
|
|
22
|
-
"
|
|
23
|
-
"
|
|
24
|
-
"
|
|
25
|
-
"
|
|
26
|
-
"
|
|
8
|
+
"headless",
|
|
9
|
+
"accessible",
|
|
10
|
+
"components",
|
|
11
|
+
"widgets",
|
|
12
|
+
"aria",
|
|
13
|
+
"wai-aria",
|
|
14
|
+
"select",
|
|
15
|
+
"dialog",
|
|
16
|
+
"grid",
|
|
17
|
+
"combobox",
|
|
18
|
+
"tabs",
|
|
27
19
|
"rip",
|
|
28
20
|
"rip-lang"
|
|
29
21
|
],
|
|
@@ -39,23 +31,10 @@
|
|
|
39
31
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
40
32
|
"license": "MIT",
|
|
41
33
|
"dependencies": {
|
|
42
|
-
"rip-lang": "
|
|
34
|
+
"rip-lang": ">=3.13.56"
|
|
43
35
|
},
|
|
44
36
|
"files": [
|
|
45
|
-
"
|
|
46
|
-
"stash.js",
|
|
47
|
-
"vfs.js",
|
|
48
|
-
"router.js",
|
|
49
|
-
"renderer.js",
|
|
50
|
-
"serve.rip",
|
|
37
|
+
"*.rip",
|
|
51
38
|
"README.md"
|
|
52
|
-
]
|
|
53
|
-
"peerDependencies": {
|
|
54
|
-
"@rip-lang/api": ">=1.1.4"
|
|
55
|
-
},
|
|
56
|
-
"peerDependenciesMeta": {
|
|
57
|
-
"@rip-lang/api": {
|
|
58
|
-
"optional": true
|
|
59
|
-
}
|
|
60
|
-
}
|
|
39
|
+
]
|
|
61
40
|
}
|
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
|
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
|