@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.
@@ -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/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/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