@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
package/slider.rip ADDED
@@ -0,0 +1,165 @@
1
+ # Slider — accessible headless range slider
2
+ #
3
+ # Supports single and range (multi-thumb) modes, pointer drag with capture,
4
+ # keyboard stepping, and CSS custom properties for thumb/indicator positioning.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Slider value <=> volume
9
+ # Slider value <=> volume, min: 0, max: 100, step: 5
10
+ # Slider value <=> range, min: 0, max: 100 (pass array for range mode)
11
+
12
+ export Slider = component
13
+ @value := 0
14
+ @min := 0
15
+ @max := 100
16
+ @step := 1
17
+ @largeStep := 10
18
+ @orientation := 'horizontal'
19
+ @disabled := false
20
+ @name := null
21
+ @valueText := null
22
+
23
+ dragging := false
24
+ activeThumb := -1
25
+ _thumbOffset = 0
26
+ _id =! "sld-#{Math.random().toString(36).slice(2, 8)}"
27
+
28
+ isRange ~= Array.isArray(@value)
29
+ values ~= if isRange then @value else [@value]
30
+ horiz ~= @orientation is 'horizontal'
31
+
32
+ _clamp: (v) -> Math.min(@max, Math.max(@min, v))
33
+
34
+ _roundToStep: (v) ->
35
+ rounded = Math.round((v - @min) / @step) * @step + @min
36
+ precision = String(@step).split('.')[1]?.length or 0
37
+ parseFloat rounded.toFixed(precision)
38
+
39
+ _percentOf: (v) -> ((@_clamp(v) - @min) / (@max - @min)) * 100
40
+
41
+ _valueFromPointer: (e) ->
42
+ rect = @_track.getBoundingClientRect()
43
+ if horiz
44
+ ratio = (e.clientX - _thumbOffset - rect.left) / rect.width
45
+ else
46
+ ratio = 1 - (e.clientY - _thumbOffset - rect.top) / rect.height
47
+ ratio = Math.max(0, Math.min(1, ratio))
48
+ @_roundToStep(@min + ratio * (@max - @min))
49
+
50
+ _closestThumb: (e) ->
51
+ return 0 unless isRange
52
+ rect = @_track.getBoundingClientRect()
53
+ pos = if horiz then (e.clientX - rect.left) / rect.width else 1 - (e.clientY - rect.top) / rect.height
54
+ best = 0
55
+ bestDist = Infinity
56
+ for v, i in values
57
+ pct = @_percentOf(v) / 100
58
+ dist = Math.abs(pos - pct)
59
+ if dist < bestDist
60
+ bestDist = dist
61
+ best = i
62
+ best
63
+
64
+ _setValue: (idx, val) ->
65
+ val = @_clamp(@_roundToStep(val))
66
+ if isRange
67
+ arr = [...values]
68
+ arr[idx] = val
69
+ arr.sort (a, b) -> a - b
70
+ @value = arr
71
+ else
72
+ @value = val
73
+ @emit 'input', @value
74
+
75
+ _commitValue: ->
76
+ @emit 'change', @value
77
+
78
+ _onPointerDown: (e) ->
79
+ return if @disabled or e.button isnt 0
80
+ e.preventDefault()
81
+ idx = @_closestThumb(e)
82
+ activeThumb = idx
83
+ dragging = true
84
+
85
+ thumb = @_track.querySelectorAll('[data-thumb]')[idx]
86
+ if thumb
87
+ tr = thumb.getBoundingClientRect()
88
+ if horiz
89
+ _thumbOffset = e.clientX - (tr.left + tr.width / 2)
90
+ else
91
+ _thumbOffset = e.clientY - (tr.top + tr.height / 2)
92
+ else
93
+ _thumbOffset = 0
94
+
95
+ newVal = @_valueFromPointer(e)
96
+ @_setValue idx, newVal
97
+
98
+ @_track.setPointerCapture e.pointerId
99
+
100
+ _onPointerMove: (e) ->
101
+ return unless dragging
102
+ newVal = @_valueFromPointer(e)
103
+ @_setValue activeThumb, newVal
104
+
105
+ _onPointerUp: (e) ->
106
+ return unless dragging
107
+ dragging = false
108
+ activeThumb = -1
109
+ _thumbOffset = 0
110
+ @_track.releasePointerCapture e.pointerId
111
+ @_commitValue()
112
+
113
+ _onKeydown: (e, idx) ->
114
+ s = if e.shiftKey then @largeStep else @step
115
+ v = values[idx]
116
+ newVal = switch e.key
117
+ when 'ArrowRight', 'ArrowUp' then v + s
118
+ when 'ArrowLeft', 'ArrowDown' then v - s
119
+ when 'PageUp' then v + @largeStep
120
+ when 'PageDown' then v - @largeStep
121
+ when 'Home' then @min
122
+ when 'End' then @max
123
+ else null
124
+ if newVal?
125
+ e.preventDefault()
126
+ @_setValue idx, newVal
127
+ @_commitValue()
128
+
129
+ render
130
+ div role: "group", $orientation: @orientation, $disabled: @disabled?!, $dragging: dragging?!
131
+ style: "--slider-min: #{@min}; --slider-max: #{@max}"
132
+
133
+ # Track
134
+ div ref: "_track", $track: true
135
+ style: "position:relative"
136
+ @pointerdown: @_onPointerDown
137
+ @pointermove: @_onPointerMove
138
+ @pointerup: @_onPointerUp
139
+
140
+ # Indicator (filled portion)
141
+ if isRange
142
+ div $indicator: true
143
+ style: "position:absolute; #{if horiz then 'left' else 'bottom'}: #{@_percentOf(values[0])}%; #{if horiz then 'width' else 'height'}: #{@_percentOf(values[1]) - @_percentOf(values[0])}%"
144
+ else
145
+ div $indicator: true
146
+ style: "position:absolute; #{if horiz then 'left: 0; width' else 'bottom: 0; height'}: #{@_percentOf(values[0])}%"
147
+
148
+ # Thumbs
149
+ for val, idx in values
150
+ div $thumb: true, $active: (idx is activeThumb)?!
151
+ style: "position:absolute; #{if horiz then 'left' else 'bottom'}: #{@_percentOf(val)}%; z-index: #{if idx is activeThumb then 2 else 1}"
152
+ @keydown: (e) => @_onKeydown(e, idx)
153
+ input type: "range", style: "position:absolute;opacity:0;width:0;height:0;pointer-events:none"
154
+ id: "#{_id}-thumb-#{idx}"
155
+ name: @name?!
156
+ min: @min, max: @max, step: @step
157
+ value: val
158
+ aria-valuenow: val
159
+ aria-valuemin: @min
160
+ aria-valuemax: @max
161
+ aria-valuetext: if @valueText then @valueText(val, idx) else undefined
162
+ aria-orientation: @orientation
163
+ aria-disabled: @disabled?!
164
+
165
+ slot
package/spinner.rip ADDED
@@ -0,0 +1,17 @@
1
+ # Spinner — accessible headless loading indicator
2
+ #
3
+ # Announces loading state to screen readers via role="status".
4
+ # Exposes size as a CSS custom property for styling.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Spinner
9
+ # Spinner label: "Saving...", size: "24px"
10
+
11
+ export Spinner = component
12
+ @label := 'Loading'
13
+ @size := null
14
+
15
+ render
16
+ div role: "status", aria-label: @label
17
+ style: if @size then "--spinner-size: #{@size}" else undefined
package/table.rip ADDED
@@ -0,0 +1,27 @@
1
+ # Table — accessible headless semantic table wrapper
2
+ #
3
+ # Lightweight wrapper for HTML tables with optional caption and
4
+ # striped rows. For data-heavy tables with virtual scrolling, use Grid.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Table caption: "Team members", striped: true
9
+ # thead
10
+ # tr
11
+ # th "Name"
12
+ # th "Role"
13
+ # tbody
14
+ # tr
15
+ # td "Alice"
16
+ # td "Engineer"
17
+
18
+ export Table = component
19
+ @caption := ''
20
+ @striped := false
21
+
22
+ render
23
+ div $striped: @striped?!
24
+ table
25
+ if @caption
26
+ caption @caption
27
+ slot
package/tabs.rip ADDED
@@ -0,0 +1,124 @@
1
+ # Tabs — accessible headless tab widget
2
+ #
3
+ # Keyboard: ArrowLeft/Right (horizontal) or ArrowUp/Down (vertical) to navigate,
4
+ # Home/End for first/last. Manages focus via roving tabindex.
5
+ # Exposes $active on tabs and panels. Ships zero CSS.
6
+ #
7
+ # Props:
8
+ # active — currently active tab id (two-way bindable)
9
+ # orientation — 'horizontal' (default) or 'vertical'
10
+ # activation — 'automatic' (default, selects on focus) or 'manual' (Enter/Space to select)
11
+ #
12
+ # Usage:
13
+ # Tabs active <=> currentTab
14
+ # div $tab: "one", "Tab One"
15
+ # div $tab: "two", "Tab Two"
16
+ # div $panel: "one"
17
+ # p "Content for tab one"
18
+ # div $panel: "two"
19
+ # p "Content for tab two"
20
+
21
+ export Tabs = component
22
+ @active := null
23
+ @orientation := 'horizontal'
24
+ @activation := 'automatic'
25
+ _ready := false
26
+ _id =! "tabs-#{Math.random().toString(36).slice(2, 8)}"
27
+ activationDirection := 'none'
28
+
29
+ tabs ~=
30
+ return [] unless _ready
31
+ Array.from(@_content?.querySelectorAll('[data-tab]') or [])
32
+
33
+ panels ~=
34
+ return [] unless _ready
35
+ Array.from(@_content?.querySelectorAll('[data-panel]') or [])
36
+
37
+ mounted: ->
38
+ _ready = true
39
+ unless @active
40
+ @active = tabs[0]?.dataset.tab
41
+
42
+ ~>
43
+ return unless _ready
44
+ tabs.forEach (el) -> el.hidden = true
45
+ panels.forEach (el) =>
46
+ id = el.dataset.panel
47
+ isActive = id is @active
48
+ el.id = "#{_id}-panel-#{id}"
49
+ el.setAttribute 'role', 'tabpanel'
50
+ el.setAttribute 'aria-labelledby', "#{_id}-tab-#{id}"
51
+ el.toggleAttribute 'hidden', not isActive
52
+ el.toggleAttribute 'data-active', isActive
53
+
54
+ _isDisabled: (el) -> el?.hasAttribute('data-disabled')
55
+
56
+ select: (id) ->
57
+ prev = @active
58
+ horiz = @orientation is 'horizontal'
59
+ if prev and id isnt prev
60
+ oldTab = tabs.find (t) -> t.dataset.tab is prev
61
+ newTab = tabs.find (t) -> t.dataset.tab is id
62
+ if oldTab and newTab
63
+ oldRect = oldTab.getBoundingClientRect()
64
+ newRect = newTab.getBoundingClientRect()
65
+ activationDirection = if horiz
66
+ if newRect.left > oldRect.left then 'right' else 'left'
67
+ else
68
+ if newRect.top > oldRect.top then 'down' else 'up'
69
+ @active = id
70
+ @emit 'change', id
71
+
72
+ _nextEnabled: (ids, from, dir) ->
73
+ len = ids.length
74
+ i = from
75
+ loop len
76
+ i = (i + dir) %% len
77
+ tab = tabs.find (t) -> t.dataset.tab is ids[i]
78
+ return ids[i] unless @_isDisabled(tab)
79
+ ids[from]
80
+
81
+ onKeydown: (e) ->
82
+ ids = tabs.map (t) -> t.dataset.tab
83
+ idx = ids.indexOf @active
84
+ return if idx is -1
85
+
86
+ horiz = @orientation is 'horizontal'
87
+ prevKey = if horiz then 'ArrowLeft' else 'ArrowUp'
88
+ nextKey = if horiz then 'ArrowRight' else 'ArrowDown'
89
+
90
+ next = switch e.key
91
+ when nextKey then @_nextEnabled(ids, idx, 1)
92
+ when prevKey then @_nextEnabled(ids, idx, -1)
93
+ when 'Home' then @_nextEnabled(ids, ids.length - 1, 1)
94
+ when 'End' then @_nextEnabled(ids, 0, -1)
95
+ when 'Enter', ' '
96
+ if @activation is 'manual'
97
+ e.preventDefault()
98
+ @select(ids[idx])
99
+ null
100
+ else null
101
+
102
+ if next
103
+ e.preventDefault()
104
+ tab = tabs.find (t) -> t.dataset.tab is next
105
+ tab?.focus()
106
+ @select(next) if @activation is 'automatic'
107
+
108
+ render
109
+ .
110
+ div role: "tablist", aria-orientation: @orientation, data-activation-direction: activationDirection, @keydown: @onKeydown
111
+ for tab in tabs
112
+ button role: "tab"
113
+ id: "#{_id}-tab-#{tab.dataset.tab}"
114
+ aria-selected: tab.dataset.tab is @active
115
+ aria-controls: "#{_id}-panel-#{tab.dataset.tab}"
116
+ aria-disabled: @_isDisabled(tab)?!
117
+ tabindex: if @_isDisabled(tab) then '-1' else (tab.dataset.tab is @active ? '0' : '-1')
118
+ $active: (tab.dataset.tab is @active)?!
119
+ $disabled: @_isDisabled(tab)?!
120
+ @click: (=> @select(tab.dataset.tab) unless @_isDisabled(tab))
121
+ = tab.textContent
122
+
123
+ . ref: "_content"
124
+ slot
package/textarea.rip ADDED
@@ -0,0 +1,48 @@
1
+ # Textarea — accessible headless auto-resizing text area
2
+ #
3
+ # Tracks focus, validation, and disabled state via data attributes.
4
+ # Optional auto-resize adjusts height to fit content. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Textarea value <=> bio, placeholder: "Tell us about yourself"
8
+ # Textarea value <=> notes, autoResize: true, rows: 3
9
+
10
+ export Textarea = component
11
+ @value := ''
12
+ @placeholder := ''
13
+ @disabled := false
14
+ @required := false
15
+ @rows := 3
16
+ @autoResize := false
17
+
18
+ focused := false
19
+ touched := false
20
+
21
+ onInput: (e) ->
22
+ @value = e.target.value
23
+ @_resize(e.target) if @autoResize
24
+
25
+ onFocus: -> focused = true
26
+ onBlur: ->
27
+ focused = false
28
+ touched = true
29
+
30
+ _resize: (el) ->
31
+ el.style.height = 'auto'
32
+ el.style.height = "#{el.scrollHeight}px"
33
+
34
+ mounted: ->
35
+ @_resize(@_root) if @autoResize and @value
36
+
37
+ render
38
+ textarea ref: "_root", value: @value, placeholder: @placeholder, rows: @rows
39
+ disabled: @disabled
40
+ required: @required
41
+ aria-disabled: @disabled?!
42
+ aria-required: @required?!
43
+ $disabled: @disabled?!
44
+ $focused: focused?!
45
+ $touched: touched?!
46
+ @input: @onInput
47
+ @focusin: @onFocus
48
+ @focusout: @onBlur
package/toast.rip ADDED
@@ -0,0 +1,87 @@
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
+
28
+ render
29
+ div role: "region", aria-label: "Notifications", $placement: @placement
30
+ for toast in @toasts
31
+ Toast toast: toast, @dismiss: (e) => @_onDismiss(e.detail)
32
+
33
+ export Toast = component
34
+ @toast := {}
35
+
36
+ leaving := false
37
+ _timer := null
38
+ _remaining = 0
39
+ _started = 0
40
+
41
+ _startTimer: ->
42
+ dur = @toast.duration ?? 4000
43
+ return unless dur > 0
44
+ _remaining = dur
45
+ _started = Date.now()
46
+ _timer = setTimeout => @dismiss(), dur
47
+
48
+ _pauseTimer: ->
49
+ return unless _timer
50
+ clearTimeout _timer
51
+ _remaining -= Date.now() - _started
52
+ _timer = null
53
+
54
+ _resumeTimer: ->
55
+ return if _timer or _remaining <= 0
56
+ _started = Date.now()
57
+ _timer = setTimeout => @dismiss(), _remaining
58
+
59
+ mounted: -> @_startTimer()
60
+
61
+ beforeUnmount: ->
62
+ clearTimeout _timer if _timer
63
+
64
+ dismiss: ->
65
+ leaving = true
66
+ setTimeout =>
67
+ @emit 'dismiss', @toast
68
+ , 200
69
+
70
+ render
71
+ div role: (if @toast.type is 'error' then 'alert' else 'status'),
72
+ aria-live: (if @toast.type is 'error' then 'assertive' else 'polite'),
73
+ $type: @toast.type ?? 'info',
74
+ $leaving: leaving?!,
75
+ @mouseenter: @_pauseTimer,
76
+ @mouseleave: @_resumeTimer,
77
+ @focusin: @_pauseTimer,
78
+ @focusout: @_resumeTimer
79
+ .
80
+ if @toast.title
81
+ strong @toast.title
82
+ span @toast.message
83
+ if @toast.action
84
+ button @click: @toast.action.onClick
85
+ @toast.action.label or 'Action'
86
+ button aria-label: "Dismiss", @click: @dismiss
87
+ "✕"
@@ -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