@rip-lang/ui 0.3.0 → 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/toast.rip ADDED
@@ -0,0 +1,88 @@
1
+ # Toast — accessible headless toast notification system
2
+ #
3
+ # Managed toast system with stacking, timer pause on hover, and promise support.
4
+ # Uses ARIA live region for screen reader announcements. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # toasts := []
8
+ #
9
+ # # Add a toast — reactive assignment is the API
10
+ # toasts = [...toasts, { message: "Saved!", type: "success" }]
11
+ #
12
+ # # Dismiss — filter it out
13
+ # toasts = toasts.filter (t) -> t isnt target
14
+ #
15
+ # # Clear all
16
+ # toasts = []
17
+ #
18
+ # # In render block
19
+ # ToastViewport toasts <=> toasts
20
+
21
+ export ToastViewport = component
22
+ @toasts := []
23
+ @placement := 'bottom-right'
24
+
25
+ onDismiss: (toast) ->
26
+ @toasts = @toasts.filter (t) -> t isnt toast
27
+ @emit 'dismiss', toast
28
+
29
+ render
30
+ div role: "region", aria-label: "Notifications", $placement: @placement
31
+ for toast in @toasts
32
+ Toast toast: toast, @dismiss: (e) => @onDismiss(e.detail)
33
+
34
+ export Toast = component
35
+ @toast := {}
36
+
37
+ leaving := false
38
+ _timer := null
39
+ _remaining = 0
40
+ _started = 0
41
+
42
+ _startTimer: ->
43
+ dur = @toast.duration ?? 4000
44
+ return unless dur > 0
45
+ _remaining = dur
46
+ _started = Date.now()
47
+ _timer = setTimeout => @dismiss(), dur
48
+
49
+ _pauseTimer: ->
50
+ return unless _timer
51
+ clearTimeout _timer
52
+ _remaining -= Date.now() - _started
53
+ _timer = null
54
+
55
+ _resumeTimer: ->
56
+ return if _timer or _remaining <= 0
57
+ _started = Date.now()
58
+ _timer = setTimeout => @dismiss(), _remaining
59
+
60
+ mounted: -> @_startTimer()
61
+
62
+ beforeUnmount: ->
63
+ clearTimeout _timer if _timer
64
+
65
+ dismiss: ->
66
+ leaving = true
67
+ setTimeout =>
68
+ @emit 'dismiss', @toast
69
+ , 200
70
+
71
+ render
72
+ div role: (if @toast.type is 'error' then 'alert' else 'status'),
73
+ aria-live: (if @toast.type is 'error' then 'assertive' else 'polite'),
74
+ $type: @toast.type ?? 'info',
75
+ $leaving: leaving?!,
76
+ @mouseenter: @_pauseTimer,
77
+ @mouseleave: @_resumeTimer,
78
+ @focusin: @_pauseTimer,
79
+ @focusout: @_resumeTimer
80
+ .
81
+ if @toast.title
82
+ strong @toast.title
83
+ span @toast.message
84
+ if @toast.action
85
+ button @click: @toast.action.onClick
86
+ @toast.action.label or 'Action'
87
+ button aria-label: "Dismiss", @click: @dismiss
88
+ "✕"
@@ -0,0 +1,78 @@
1
+ # ToggleGroup — accessible headless toggle group
2
+ #
3
+ # A set of two-state buttons where one or more can be pressed.
4
+ # Set @multiple to false for single-select (radio-like) behavior.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # ToggleGroup value <=> alignment
9
+ # div $value: "left", "Left"
10
+ # div $value: "center", "Center"
11
+ # div $value: "right", "Right"
12
+
13
+ export ToggleGroup = component
14
+ @value := null
15
+ @disabled := false
16
+ @multiple := false
17
+ @orientation := 'horizontal'
18
+
19
+ _items ~=
20
+ return [] unless @_slot
21
+ Array.from(@_slot.querySelectorAll('[data-value]') or [])
22
+
23
+ _isPressed: (item) ->
24
+ val = item.dataset.value
25
+ if @multiple
26
+ Array.isArray(@value) and val in @value
27
+ else
28
+ val is @value
29
+
30
+ _toggle: (val) ->
31
+ return if @disabled
32
+ if @multiple
33
+ arr = if Array.isArray(@value) then [...@value] else []
34
+ if val in arr
35
+ arr = arr.filter (v) -> v isnt val
36
+ else
37
+ arr.push val
38
+ @value = arr
39
+ else
40
+ @value = if val is @value then null else val
41
+ @emit 'change', @value
42
+
43
+ onKeydown: (e) ->
44
+ opts = @_root?.querySelectorAll('[data-value]')
45
+ return unless opts?.length
46
+ focused = Array.from(opts).indexOf(document.activeElement)
47
+ return if focused < 0
48
+ len = opts.length
49
+ switch e.key
50
+ when 'ArrowRight', 'ArrowDown'
51
+ e.preventDefault()
52
+ opts[(focused + 1) %% len]?.focus()
53
+ when 'ArrowLeft', 'ArrowUp'
54
+ e.preventDefault()
55
+ opts[(focused - 1) %% len]?.focus()
56
+ when 'Home'
57
+ e.preventDefault()
58
+ opts[0]?.focus()
59
+ when 'End'
60
+ e.preventDefault()
61
+ opts[len - 1]?.focus()
62
+
63
+ render
64
+ div ref: "_root", role: "group", aria-orientation: @orientation
65
+ $orientation: @orientation
66
+ $disabled: @disabled?!
67
+
68
+ . ref: "_slot", style: "display:none"
69
+ slot
70
+
71
+ for item, idx in _items
72
+ button tabindex: (if idx is 0 then "0" else "-1")
73
+ aria-pressed: !!@_isPressed(item)
74
+ $pressed: @_isPressed(item)?!
75
+ $disabled: @disabled?!
76
+ $value: item.dataset.value
77
+ @click: (=> @_toggle(item.dataset.value))
78
+ item.textContent
package/toggle.rip ADDED
@@ -0,0 +1,24 @@
1
+ # Toggle — accessible headless toggle button
2
+ #
3
+ # Stateful button that toggles pressed state on click.
4
+ # Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Toggle pressed <=> isBold
8
+ # "Bold"
9
+
10
+ export Toggle = component
11
+ @pressed := false
12
+ @disabled := false
13
+
14
+ onClick: ->
15
+ return if @disabled
16
+ @pressed = not @pressed
17
+ @emit 'change', @pressed
18
+
19
+ render
20
+ button aria-pressed: !!@pressed
21
+ aria-disabled: @disabled?!
22
+ $pressed: @pressed?!
23
+ $disabled: @disabled?!
24
+ slot
package/toolbar.rip ADDED
@@ -0,0 +1,46 @@
1
+ # Toolbar — accessible headless toolbar
2
+ #
3
+ # Groups interactive controls with roving tabindex keyboard navigation.
4
+ # Arrow keys move focus between focusable children. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Toolbar
8
+ # Button @click: save, "Save"
9
+ # Button @click: undo, "Undo"
10
+ # Separator orientation: "vertical"
11
+ # Toggle pressed <=> isBold, "Bold"
12
+
13
+ export Toolbar = component
14
+ @orientation := 'horizontal'
15
+ @label := ''
16
+
17
+ _getFocusable: ->
18
+ return [] unless @_root
19
+ Array.from(@_root.querySelectorAll('button, [tabindex], input, select, textarea')).filter (el) ->
20
+ not el.disabled and el.offsetParent isnt null
21
+
22
+ onKeydown: (e) ->
23
+ els = @_getFocusable()
24
+ return unless els.length
25
+ focused = els.indexOf(document.activeElement)
26
+ return if focused < 0
27
+ len = els.length
28
+ horiz = @orientation is 'horizontal'
29
+ switch e.key
30
+ when (if horiz then 'ArrowRight' else 'ArrowDown')
31
+ e.preventDefault()
32
+ els[(focused + 1) %% len]?.focus()
33
+ when (if horiz then 'ArrowLeft' else 'ArrowUp')
34
+ e.preventDefault()
35
+ els[(focused - 1) %% len]?.focus()
36
+ when 'Home'
37
+ e.preventDefault()
38
+ els[0]?.focus()
39
+ when 'End'
40
+ e.preventDefault()
41
+ els[len - 1]?.focus()
42
+
43
+ render
44
+ div role: "toolbar", aria-label: @label or undefined, aria-orientation: @orientation
45
+ $orientation: @orientation
46
+ slot
package/tooltip.rip ADDED
@@ -0,0 +1,115 @@
1
+ # Tooltip — accessible headless tooltip with delay and positioning
2
+ #
3
+ # Shows on hover/focus with configurable delay. Uses aria-describedby.
4
+ # Exposes $open, $entering, $exiting. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Tooltip text: "Helpful info", placement: "top"
8
+ # button "Hover me"
9
+
10
+ lastCloseTime = 0
11
+ GROUP_TIMEOUT = 400
12
+
13
+ export Tooltip = component
14
+ @text := ''
15
+ @placement := 'top'
16
+ @delay := 300
17
+ @offset := 6
18
+ @hoverable := false
19
+
20
+ open := false
21
+ entering := false
22
+ exiting := false
23
+ _showTimer := null
24
+ _hideTimer := null
25
+ _id =! "tip-#{Math.random().toString(36).slice(2, 8)}"
26
+
27
+ show: ->
28
+ clearTimeout _hideTimer if _hideTimer
29
+ delay = if (Date.now() - lastCloseTime) < GROUP_TIMEOUT then 0 else @delay
30
+ _showTimer = setTimeout =>
31
+ open = true
32
+ entering = true
33
+ setTimeout =>
34
+ entering = false
35
+ @_position()
36
+ , 0
37
+ , delay
38
+
39
+ hide: ->
40
+ clearTimeout _showTimer if _showTimer
41
+ exiting = true
42
+ _hideTimer = setTimeout =>
43
+ open = false
44
+ exiting = false
45
+ lastCloseTime = Date.now()
46
+ , 150
47
+
48
+ _cancelHide: ->
49
+ clearTimeout _hideTimer if _hideTimer
50
+ exiting = false
51
+
52
+ _position: ->
53
+ return unless @_trigger and @_tip
54
+ tr = @_trigger.getBoundingClientRect()
55
+ fl = @_tip.getBoundingClientRect()
56
+ [side, align] = @placement.split('-')
57
+ align ?= 'center'
58
+ gap = @offset
59
+
60
+ x = switch side
61
+ when 'bottom', 'top'
62
+ switch align
63
+ when 'start' then tr.left
64
+ when 'end' then tr.right - fl.width
65
+ else tr.left + (tr.width - fl.width) / 2
66
+ when 'right' then tr.right + gap
67
+ when 'left' then tr.left - fl.width - gap
68
+
69
+ y = switch side
70
+ when 'bottom' then tr.bottom + gap
71
+ when 'top' then tr.top - fl.height - gap
72
+ when 'left', 'right'
73
+ switch align
74
+ when 'start' then tr.top
75
+ when 'end' then tr.bottom - fl.height
76
+ else tr.top + (tr.height - fl.height) / 2
77
+
78
+ if side is 'bottom' and y + fl.height > window.innerHeight
79
+ y = tr.top - fl.height - gap
80
+ if side is 'top' and y < 0
81
+ y = tr.bottom + gap
82
+ if side is 'right' and x + fl.width > window.innerWidth
83
+ x = tr.left - fl.width - gap
84
+ if side is 'left' and x < 0
85
+ x = tr.right + gap
86
+
87
+ x = Math.max(4, Math.min(x, window.innerWidth - fl.width - 4))
88
+
89
+ @_tip.style.position = 'fixed'
90
+ @_tip.style.left = "#{x}px"
91
+ @_tip.style.top = "#{y}px"
92
+
93
+ beforeUnmount: ->
94
+ clearTimeout _showTimer if _showTimer
95
+ clearTimeout _hideTimer if _hideTimer
96
+
97
+ render
98
+ .
99
+ div ref: "_trigger"
100
+ aria-describedby: open ? _id : undefined
101
+ @mouseenter: @show
102
+ @mouseleave: @hide
103
+ @focusin: @show
104
+ @focusout: @hide
105
+ slot
106
+
107
+ if open
108
+ div ref: "_tip", id: _id, role: "tooltip"
109
+ $open: true
110
+ $entering: entering?!
111
+ $exiting: exiting?!
112
+ $placement: @placement
113
+ @mouseenter: (=> @_cancelHide() if @hoverable)
114
+ @mouseleave: (=> @hide() if @hoverable)
115
+ @text
package/serve.rip DELETED
@@ -1,140 +0,0 @@
1
- # ==============================================================================
2
- # @rip-lang/ui/serve — Rip UI Server Middleware
3
- # ==============================================================================
4
- #
5
- # Serves the Rip UI runtime, auto-generated app bundles, and optional
6
- # SSE hot-reload.
7
- #
8
- # Usage:
9
- # import { ripUI } from '@rip-lang/ui/serve'
10
- # use ripUI app: '/demo', dir: dir, title: 'My App'
11
- #
12
- # Options:
13
- # app: string — URL mount point (default: '')
14
- # dir: string — app directory on disk (default: '.')
15
- # components: string — components subdirectory name (default: 'components')
16
- # watch: boolean — enable SSE hot-reload endpoint (default: false)
17
- # debounce: number — ms to batch filesystem events (default: 250)
18
- # state: object — initial app state passed via bundle
19
- # title: string — document title
20
- #
21
- # ==============================================================================
22
-
23
- import { get } from '@rip-lang/api'
24
- import { watch as fsWatch } from 'node:fs'
25
-
26
- export ripUI = (opts = {}) ->
27
- prefix = opts.app or ''
28
- appDir = opts.dir or '.'
29
- componentsDir = opts.components or "#{appDir}/components"
30
- enableWatch = opts.watch or false
31
- debounceMs = opts.debounce or 250
32
- appState = opts.state or null
33
- appTitle = opts.title or null
34
- uiDir = import.meta.dir
35
-
36
- # Resolve compiler (rip.browser.js)
37
- compilerPath = null
38
- try
39
- compilerPath = Bun.fileURLToPath(import.meta.resolve('rip-lang/docs/dist/rip.browser.js'))
40
- catch
41
- compilerPath = "#{uiDir}/../../docs/dist/rip.browser.js"
42
-
43
- # ----------------------------------------------------------------------------
44
- # Route: /rip/* — framework files (compiler + ui.rip), registered once
45
- # ----------------------------------------------------------------------------
46
-
47
- unless ripUI._registered
48
- get "/rip/browser.js", (c) -> c.send compilerPath, 'application/javascript'
49
- get "/rip/ui.rip", (c) -> c.send "#{uiDir}/ui.rip", 'text/plain; charset=UTF-8'
50
- ripUI._registered = true
51
-
52
- # ----------------------------------------------------------------------------
53
- # Route: {prefix}/components/* — individual .rip component files (for hot-reload)
54
- # Route: {prefix}/bundle — app bundle (components + data as JSON)
55
- # Route: {prefix}/watch — SSE hot-reload stream
56
- # ----------------------------------------------------------------------------
57
-
58
- get "#{prefix}/components/*", (c) ->
59
- name = c.req.path.slice("#{prefix}/components/".length)
60
- c.send "#{componentsDir}/#{name}", 'text/plain; charset=UTF-8'
61
-
62
- bundleCache = null
63
- bundleDirty = true
64
-
65
- # Invalidate bundle cache when components change
66
- if enableWatch
67
- fsWatch componentsDir, { recursive: true }, (event, filename) ->
68
- bundleDirty = true if filename?.endsWith('.rip')
69
-
70
- get "#{prefix}/bundle", (c) ->
71
- if bundleDirty or not bundleCache
72
- glob = new Bun.Glob("**/*.rip")
73
- components = {}
74
- paths = Array.from(glob.scanSync(componentsDir))
75
- for path in paths
76
- components["components/#{path}"] = Bun.file("#{componentsDir}/#{path}").text!
77
-
78
- data = {}
79
- data.title = appTitle if appTitle
80
- data.watch = enableWatch
81
- if appState
82
- data[k] = v for k, v of appState
83
-
84
- bundleCache = JSON.stringify({ components, data })
85
- bundleDirty = false
86
-
87
- new Response bundleCache, headers: { 'Content-Type': 'application/json' }
88
-
89
- if enableWatch
90
- get "#{prefix}/watch", (c) ->
91
- encoder = new TextEncoder()
92
- pending = new Set()
93
- timer = null
94
- watcher = null
95
- heartbeat = null
96
-
97
- cleanup = ->
98
- watcher?.close()
99
- clearTimeout(timer) if timer
100
- clearInterval(heartbeat) if heartbeat
101
- watcher = heartbeat = timer = null
102
-
103
- new Response new ReadableStream(
104
- start: (controller) ->
105
- send = (event, data) ->
106
- try
107
- controller.enqueue encoder.encode("event: #{event}\ndata: #{JSON.stringify(data)}\n\n")
108
- catch
109
- cleanup()
110
-
111
- send 'connected', { time: Date.now() }
112
-
113
- heartbeat = setInterval ->
114
- try
115
- controller.enqueue encoder.encode(": heartbeat\n\n")
116
- catch
117
- cleanup()
118
- , 5000
119
-
120
- flush = ->
121
- paths = Array.from(pending)
122
- pending.clear()
123
- timer = null
124
- send('changed', { paths }) if paths.length > 0
125
-
126
- watcher = fsWatch componentsDir, { recursive: true }, (event, filename) ->
127
- return unless filename?.endsWith('.rip')
128
- pending.add "components/#{filename}"
129
- clearTimeout(timer) if timer
130
- timer = setTimeout(flush, debounceMs)
131
-
132
- cancel: -> cleanup()
133
- ),
134
- headers:
135
- 'Content-Type': 'text/event-stream'
136
- 'Cache-Control': 'no-cache'
137
- 'Connection': 'keep-alive'
138
-
139
- # Return pass-through middleware
140
- (c, next) -> next!()