@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.
Files changed (61) hide show
  1. package/README.md +442 -572
  2. package/accordion.rip +113 -0
  3. package/alert-dialog.rip +96 -0
  4. package/autocomplete.rip +141 -0
  5. package/avatar.rip +37 -0
  6. package/badge.rip +15 -0
  7. package/breadcrumb.rip +46 -0
  8. package/button-group.rip +26 -0
  9. package/button.rip +23 -0
  10. package/card.rip +25 -0
  11. package/carousel.rip +110 -0
  12. package/checkbox-group.rip +65 -0
  13. package/checkbox.rip +33 -0
  14. package/collapsible.rip +50 -0
  15. package/combobox.rip +155 -0
  16. package/context-menu.rip +105 -0
  17. package/date-picker.rip +214 -0
  18. package/dialog.rip +107 -0
  19. package/drawer.rip +79 -0
  20. package/editable-value.rip +80 -0
  21. package/field.rip +53 -0
  22. package/fieldset.rip +22 -0
  23. package/form.rip +39 -0
  24. package/grid.rip +901 -0
  25. package/index.rip +16 -0
  26. package/input-group.rip +28 -0
  27. package/input.rip +36 -0
  28. package/label.rip +16 -0
  29. package/menu.rip +162 -0
  30. package/menubar.rip +155 -0
  31. package/meter.rip +36 -0
  32. package/multi-select.rip +158 -0
  33. package/native-select.rip +32 -0
  34. package/nav-menu.rip +129 -0
  35. package/number-field.rip +162 -0
  36. package/otp-field.rip +89 -0
  37. package/package.json +18 -27
  38. package/pagination.rip +123 -0
  39. package/popover.rip +143 -0
  40. package/preview-card.rip +73 -0
  41. package/progress.rip +25 -0
  42. package/radio-group.rip +67 -0
  43. package/resizable.rip +123 -0
  44. package/scroll-area.rip +145 -0
  45. package/select.rip +184 -0
  46. package/separator.rip +17 -0
  47. package/skeleton.rip +22 -0
  48. package/slider.rip +165 -0
  49. package/spinner.rip +17 -0
  50. package/table.rip +27 -0
  51. package/tabs.rip +124 -0
  52. package/textarea.rip +48 -0
  53. package/toast.rip +87 -0
  54. package/toggle-group.rip +78 -0
  55. package/toggle.rip +24 -0
  56. package/toolbar.rip +46 -0
  57. package/tooltip.rip +115 -0
  58. package/dist/rip-ui.min.js +0 -522
  59. package/dist/rip-ui.min.js.br +0 -0
  60. package/serve.rip +0 -92
  61. package/ui.rip +0 -964
@@ -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
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
@@ -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