rip-lang 3.13.65 → 3.13.67
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/README.md +1 -1
- package/docs/dist/rip.js +28 -8
- package/docs/dist/rip.min.js +16 -16
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/index.html +4 -0
- package/docs/ui/accordion.rip +113 -0
- package/docs/ui/autocomplete.rip +141 -0
- package/docs/ui/avatar.rip +37 -0
- package/docs/ui/button.rip +23 -0
- package/docs/ui/checkbox-group.rip +65 -0
- package/docs/ui/checkbox.rip +33 -0
- package/docs/ui/combobox.rip +155 -0
- package/docs/ui/context-menu.rip +105 -0
- package/docs/ui/date-picker.rip +214 -0
- package/docs/ui/dialog.rip +107 -0
- package/docs/ui/drawer.rip +79 -0
- package/docs/ui/editable-value.rip +80 -0
- package/docs/ui/field.rip +53 -0
- package/docs/ui/fieldset.rip +22 -0
- package/docs/ui/form.rip +39 -0
- package/docs/ui/grid.rip +901 -0
- package/docs/ui/index.css +1379 -0
- package/docs/ui/index.html +2097 -0
- package/docs/ui/input.rip +36 -0
- package/docs/ui/menu.rip +162 -0
- package/docs/ui/menubar.rip +155 -0
- package/docs/ui/meter.rip +36 -0
- package/docs/ui/multi-select.rip +158 -0
- package/docs/ui/nav-menu.rip +129 -0
- package/docs/ui/number-field.rip +162 -0
- package/docs/ui/otp-field.rip +89 -0
- package/docs/ui/popover.rip +143 -0
- package/docs/ui/preview-card.rip +73 -0
- package/docs/ui/progress.rip +25 -0
- package/docs/ui/radio-group.rip +67 -0
- package/docs/ui/scroll-area.rip +145 -0
- package/docs/ui/select.rip +184 -0
- package/docs/ui/separator.rip +17 -0
- package/docs/ui/slider.rip +165 -0
- package/docs/ui/tabs.rip +124 -0
- package/docs/ui/toast.rip +87 -0
- package/docs/ui/toggle-group.rip +78 -0
- package/docs/ui/toggle.rip +24 -0
- package/docs/ui/toolbar.rip +46 -0
- package/docs/ui/tooltip.rip +115 -0
- package/package.json +2 -1
- package/src/app.rip +1 -1
- package/src/components.js +3 -4
- package/src/lexer.js +1 -1
|
@@ -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
|
|
@@ -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
|
|
@@ -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/docs/ui/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
|
|
@@ -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
|
|
@@ -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
|