@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.
@@ -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.3",
4
- "description": "Zero-build reactive web frameworkVFS, file-based routing, reactive stash",
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
- "framework",
21
- "reactive",
22
- "vfs",
23
- "router",
24
- "stash",
25
- "signals",
26
- "no-build",
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": "^3.4.4"
34
+ "rip-lang": ">=3.13.56"
43
35
  },
44
36
  "files": [
45
- "ui.js",
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
@@ -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
@@ -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