@rip-lang/ui 0.1.3 → 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/README.md +507 -726
- package/accordion.rip +113 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/button.rip +23 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/combobox.rip +153 -0
- package/context-menu.rip +98 -0
- package/date-picker.rip +214 -0
- package/dialog.rip +107 -0
- package/drawer.rip +79 -0
- package/editable-value.rip +80 -0
- package/field.rip +53 -0
- package/fieldset.rip +22 -0
- package/form.rip +39 -0
- package/grid.rip +901 -0
- package/index.rip +15 -0
- package/input.rip +35 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/nav-menu.rip +132 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +16 -37
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/slider.rip +165 -0
- package/tabs.rip +124 -0
- package/toast.rip +88 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/renderer.js +0 -397
- package/router.js +0 -325
- package/serve.rip +0 -140
- package/stash.js +0 -413
- package/ui.js +0 -208
- package/vfs.js +0 -215
package/accordion.rip
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Accordion — accessible headless expand/collapse widget
|
|
2
|
+
#
|
|
3
|
+
# Supports single or multiple expanded sections. Keyboard: Enter/Space to
|
|
4
|
+
# toggle, ArrowDown/Up to move between triggers. Exposes $open on items.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Accordion multiple: false
|
|
9
|
+
# div $item: "a"
|
|
10
|
+
# button $trigger: true, "Section A"
|
|
11
|
+
# div $content: true
|
|
12
|
+
# p "Content A"
|
|
13
|
+
# div $item: "b"
|
|
14
|
+
# button $trigger: true, "Section B"
|
|
15
|
+
# div $content: true
|
|
16
|
+
# p "Content B"
|
|
17
|
+
|
|
18
|
+
export Accordion = component
|
|
19
|
+
@multiple := false
|
|
20
|
+
|
|
21
|
+
openItems := new Set()
|
|
22
|
+
_ready := false
|
|
23
|
+
_id =! "acc-#{Math.random().toString(36).slice(2, 8)}"
|
|
24
|
+
|
|
25
|
+
mounted: ->
|
|
26
|
+
_ready = true
|
|
27
|
+
@_content?.querySelectorAll('[data-trigger]').forEach (trigger) =>
|
|
28
|
+
item = trigger.closest('[data-item]')
|
|
29
|
+
return unless item
|
|
30
|
+
id = item.dataset.item
|
|
31
|
+
trigger.addEventListener 'click', =>
|
|
32
|
+
return if item.hasAttribute('data-disabled')
|
|
33
|
+
@toggle(id)
|
|
34
|
+
trigger.addEventListener 'keydown', (e) => @onTriggerKeydown(e, id)
|
|
35
|
+
|
|
36
|
+
~>
|
|
37
|
+
return unless _ready
|
|
38
|
+
@_content?.querySelectorAll('[data-item]').forEach (item) =>
|
|
39
|
+
id = item.dataset.item
|
|
40
|
+
isOpen = openItems.has(id)
|
|
41
|
+
item.toggleAttribute 'data-open', isOpen
|
|
42
|
+
trigger = item.querySelector('[data-trigger]')
|
|
43
|
+
content = item.querySelector('[data-content]')
|
|
44
|
+
triggerId = "#{_id}-trigger-#{id}"
|
|
45
|
+
panelId = "#{_id}-panel-#{id}"
|
|
46
|
+
if trigger
|
|
47
|
+
isDisabled = item.hasAttribute('data-disabled')
|
|
48
|
+
trigger.id = triggerId
|
|
49
|
+
trigger.setAttribute 'aria-expanded', isOpen
|
|
50
|
+
trigger.setAttribute 'aria-controls', panelId
|
|
51
|
+
trigger.setAttribute 'aria-disabled', isDisabled if isDisabled
|
|
52
|
+
trigger.tabIndex = if isDisabled then -1 else 0
|
|
53
|
+
if content
|
|
54
|
+
content.id = panelId
|
|
55
|
+
content.hidden = if isOpen then false else 'until-found'
|
|
56
|
+
content.setAttribute 'role', 'region'
|
|
57
|
+
content.setAttribute 'aria-labelledby', triggerId
|
|
58
|
+
if isOpen
|
|
59
|
+
rect = content.getBoundingClientRect()
|
|
60
|
+
content.style.setProperty '--accordion-panel-height', "#{rect.height}px"
|
|
61
|
+
content.style.setProperty '--accordion-panel-width', "#{rect.width}px"
|
|
62
|
+
|
|
63
|
+
toggle: (id) ->
|
|
64
|
+
if openItems.has(id)
|
|
65
|
+
openItems.delete(id)
|
|
66
|
+
else
|
|
67
|
+
openItems.clear() unless @multiple
|
|
68
|
+
openItems.add(id)
|
|
69
|
+
openItems = new Set(openItems)
|
|
70
|
+
@emit 'change', Array.from(openItems)
|
|
71
|
+
|
|
72
|
+
isOpen: (id) ->
|
|
73
|
+
openItems.has(id)
|
|
74
|
+
|
|
75
|
+
onTriggerKeydown: (e, id) ->
|
|
76
|
+
item = e.currentTarget.closest('[data-item]')
|
|
77
|
+
return if item?.hasAttribute('data-disabled') and e.key in ['Enter', ' ']
|
|
78
|
+
switch e.key
|
|
79
|
+
when 'Enter', ' '
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
@toggle(id)
|
|
82
|
+
when 'ArrowDown'
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
@_focusNext(1)
|
|
85
|
+
when 'ArrowUp'
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
@_focusNext(-1)
|
|
88
|
+
when 'Home'
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
@_focusTrigger(0)
|
|
91
|
+
when 'End'
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
@_focusTrigger(-1)
|
|
94
|
+
|
|
95
|
+
_triggers: ->
|
|
96
|
+
return [] unless @_content
|
|
97
|
+
Array.from(@_content.querySelectorAll('[data-trigger]'))
|
|
98
|
+
|
|
99
|
+
_focusNext: (dir) ->
|
|
100
|
+
triggers = @_triggers()
|
|
101
|
+
idx = triggers.indexOf(document.activeElement)
|
|
102
|
+
return if idx is -1
|
|
103
|
+
next = (idx + dir) %% triggers.length
|
|
104
|
+
triggers[next]?.focus()
|
|
105
|
+
|
|
106
|
+
_focusTrigger: (idx) ->
|
|
107
|
+
triggers = @_triggers()
|
|
108
|
+
target = if idx < 0 then triggers[triggers.length - 1] else triggers[idx]
|
|
109
|
+
target?.focus()
|
|
110
|
+
|
|
111
|
+
render
|
|
112
|
+
div ref: "_content"
|
|
113
|
+
slot
|
package/autocomplete.rip
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Autocomplete — accessible headless suggestion input
|
|
2
|
+
#
|
|
3
|
+
# Like Combobox but the input value IS the value (no selection from a list).
|
|
4
|
+
# Suggestions are shown as the user types; selecting a suggestion fills the input.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Autocomplete value <=> city, items: cities, @filter: filterCities
|
|
9
|
+
|
|
10
|
+
acCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
|
11
|
+
|
|
12
|
+
export Autocomplete = component
|
|
13
|
+
@value := ''
|
|
14
|
+
@items := []
|
|
15
|
+
@placeholder := 'Type to search...'
|
|
16
|
+
@disabled := false
|
|
17
|
+
|
|
18
|
+
open := false
|
|
19
|
+
|
|
20
|
+
filteredItems ~=
|
|
21
|
+
q = @value.trim()
|
|
22
|
+
return @items unless q
|
|
23
|
+
@items.filter (item) ->
|
|
24
|
+
label = if typeof item is 'string' then item else (item.label or item.name or String(item))
|
|
25
|
+
acCollator.compare(label.slice(0, q.length), q) is 0
|
|
26
|
+
|
|
27
|
+
_listId =! "ac-list-#{Math.random().toString(36).slice(2, 8)}"
|
|
28
|
+
|
|
29
|
+
_getItems: ->
|
|
30
|
+
return [] unless @_list
|
|
31
|
+
Array.from(@_list.querySelectorAll('[role="option"]'))
|
|
32
|
+
|
|
33
|
+
_updateHighlight: ->
|
|
34
|
+
idx = @_hlIdx
|
|
35
|
+
opts = @_getItems()
|
|
36
|
+
opts.forEach (el, ndx) ->
|
|
37
|
+
el.id = "#{@_listId}-opt-#{ndx}" unless el.id
|
|
38
|
+
el.toggleAttribute 'data-highlighted', ndx is idx
|
|
39
|
+
activeId = if idx >= 0 and opts[idx] then opts[idx].id else undefined
|
|
40
|
+
if @_input
|
|
41
|
+
if activeId then @_input.setAttribute 'aria-activedescendant', activeId
|
|
42
|
+
else @_input.removeAttribute 'aria-activedescendant'
|
|
43
|
+
opts[idx]?.scrollIntoView({ block: 'nearest' })
|
|
44
|
+
|
|
45
|
+
openMenu: ->
|
|
46
|
+
open = true
|
|
47
|
+
@_hlIdx = -1
|
|
48
|
+
setTimeout => @_position(), 0
|
|
49
|
+
|
|
50
|
+
close: ->
|
|
51
|
+
open = false
|
|
52
|
+
@_hlIdx = -1
|
|
53
|
+
|
|
54
|
+
_position: ->
|
|
55
|
+
return unless @_input and @_list
|
|
56
|
+
tr = @_input.getBoundingClientRect()
|
|
57
|
+
@_list.style.position = 'fixed'
|
|
58
|
+
@_list.style.left = "#{tr.left}px"
|
|
59
|
+
@_list.style.top = "#{tr.bottom + 2}px"
|
|
60
|
+
@_list.style.minWidth = "#{tr.width}px"
|
|
61
|
+
fl = @_list.getBoundingClientRect()
|
|
62
|
+
if fl.bottom > window.innerHeight
|
|
63
|
+
@_list.style.top = "#{tr.top - fl.height - 2}px"
|
|
64
|
+
|
|
65
|
+
selectIndex: (idx) ->
|
|
66
|
+
item = filteredItems[idx]
|
|
67
|
+
return unless item
|
|
68
|
+
label = if typeof item is 'string' then item else (item.label or item.name or String(item))
|
|
69
|
+
@value = label
|
|
70
|
+
@_input?.value = label
|
|
71
|
+
@emit 'select', item
|
|
72
|
+
@close()
|
|
73
|
+
|
|
74
|
+
onInput: (e) ->
|
|
75
|
+
newVal = e.target.value
|
|
76
|
+
return if newVal is @value
|
|
77
|
+
@value = newVal
|
|
78
|
+
open = true
|
|
79
|
+
@_hlIdx = if filteredItems.length > 0 then 0 else -1
|
|
80
|
+
setTimeout =>
|
|
81
|
+
@_position()
|
|
82
|
+
@_updateHighlight()
|
|
83
|
+
, 0
|
|
84
|
+
|
|
85
|
+
onKeydown: (e) ->
|
|
86
|
+
len = filteredItems.length
|
|
87
|
+
switch e.key
|
|
88
|
+
when 'ArrowDown'
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
@openMenu() unless open
|
|
91
|
+
if len
|
|
92
|
+
@_hlIdx = (@_hlIdx + 1) %% len
|
|
93
|
+
@_updateHighlight()
|
|
94
|
+
when 'ArrowUp'
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
@openMenu() unless open
|
|
97
|
+
if len
|
|
98
|
+
@_hlIdx = if @_hlIdx <= 0 then len - 1 else @_hlIdx - 1
|
|
99
|
+
@_updateHighlight()
|
|
100
|
+
when 'Enter'
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
@selectIndex(@_hlIdx) if @_hlIdx >= 0
|
|
103
|
+
when 'Escape'
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
@close()
|
|
106
|
+
when 'Tab'
|
|
107
|
+
@close()
|
|
108
|
+
|
|
109
|
+
~>
|
|
110
|
+
if open
|
|
111
|
+
onDown = (e) =>
|
|
112
|
+
unless @_input?.contains(e.target) or @_list?.contains(e.target)
|
|
113
|
+
@close()
|
|
114
|
+
document.addEventListener 'mousedown', onDown
|
|
115
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
116
|
+
|
|
117
|
+
mounted: ->
|
|
118
|
+
@_hlIdx = -1
|
|
119
|
+
@_input.value = @value if @_input and @value
|
|
120
|
+
|
|
121
|
+
render
|
|
122
|
+
. $open: open?!
|
|
123
|
+
|
|
124
|
+
input ref: "_input", role: "combobox", type: "text"
|
|
125
|
+
autocomplete: "off"
|
|
126
|
+
aria-expanded: !!open
|
|
127
|
+
aria-haspopup: "listbox"
|
|
128
|
+
aria-autocomplete: "list"
|
|
129
|
+
aria-controls: open ? _listId : undefined
|
|
130
|
+
$disabled: @disabled?!
|
|
131
|
+
disabled: @disabled
|
|
132
|
+
placeholder: @placeholder
|
|
133
|
+
@input: @onInput
|
|
134
|
+
|
|
135
|
+
if open and filteredItems.length > 0
|
|
136
|
+
div ref: "_list", id: _listId, role: "listbox", $open: true, style: "position:fixed"
|
|
137
|
+
for item, idx in filteredItems
|
|
138
|
+
div role: "option", tabindex: "-1"
|
|
139
|
+
@click: (=> @selectIndex(idx))
|
|
140
|
+
@mouseenter: (=> @_hlIdx = idx; @_updateHighlight())
|
|
141
|
+
"#{if typeof item is 'string' then item else (item.label or item.name or String(item))}"
|
package/avatar.rip
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Avatar — accessible headless avatar
|
|
2
|
+
#
|
|
3
|
+
# Shows an image, falls back to initials or a generic icon placeholder.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Avatar src: user.photoUrl, alt: user.name, fallback: "AC"
|
|
8
|
+
# Avatar fallback: "JD"
|
|
9
|
+
# Avatar
|
|
10
|
+
|
|
11
|
+
export Avatar = component
|
|
12
|
+
@src := ''
|
|
13
|
+
@alt := ''
|
|
14
|
+
@fallback := ''
|
|
15
|
+
|
|
16
|
+
imgError := false
|
|
17
|
+
|
|
18
|
+
_onError: -> imgError = true
|
|
19
|
+
|
|
20
|
+
_initials ~=
|
|
21
|
+
return @fallback if @fallback
|
|
22
|
+
return '' unless @alt
|
|
23
|
+
parts = @alt.trim().split(/\s+/)
|
|
24
|
+
chars = parts.map (p) -> p[0]?.toUpperCase() or ''
|
|
25
|
+
chars.slice(0, 2).join('')
|
|
26
|
+
|
|
27
|
+
render
|
|
28
|
+
span role: "img", aria-label: @alt or 'Avatar'
|
|
29
|
+
$status: if @src and not imgError then 'image' else if _initials then 'fallback' else 'placeholder'
|
|
30
|
+
if @src and not imgError
|
|
31
|
+
img src: @src, alt: @alt, @error: @_onError
|
|
32
|
+
else if _initials
|
|
33
|
+
span $initials: true
|
|
34
|
+
_initials
|
|
35
|
+
else
|
|
36
|
+
span $placeholder: true
|
|
37
|
+
"?"
|
package/button.rip
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Button — accessible headless button
|
|
2
|
+
#
|
|
3
|
+
# Handles disabled-but-focusable pattern and pressed state.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Button @click: handleClick
|
|
8
|
+
# "Save"
|
|
9
|
+
# Button disabled: true
|
|
10
|
+
# "Unavailable"
|
|
11
|
+
|
|
12
|
+
export Button = component
|
|
13
|
+
@disabled := false
|
|
14
|
+
|
|
15
|
+
onClick: ->
|
|
16
|
+
return if @disabled
|
|
17
|
+
@emit 'press'
|
|
18
|
+
|
|
19
|
+
render
|
|
20
|
+
button disabled: @disabled
|
|
21
|
+
aria-disabled: @disabled?!
|
|
22
|
+
$disabled: @disabled?!
|
|
23
|
+
slot
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# CheckboxGroup — accessible headless checkbox group
|
|
2
|
+
#
|
|
3
|
+
# Multiple options can be checked independently. Wraps individual checkboxes
|
|
4
|
+
# with group semantics. Value is an array of checked option values.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# CheckboxGroup value <=> selectedToppings
|
|
9
|
+
# div $value: "cheese", "Cheese"
|
|
10
|
+
# div $value: "bacon", "Bacon"
|
|
11
|
+
# div $value: "lettuce", "Lettuce"
|
|
12
|
+
|
|
13
|
+
export CheckboxGroup = component
|
|
14
|
+
@value := []
|
|
15
|
+
@disabled := false
|
|
16
|
+
@orientation := 'vertical'
|
|
17
|
+
@label := ''
|
|
18
|
+
|
|
19
|
+
_options ~=
|
|
20
|
+
return [] unless @_slot
|
|
21
|
+
Array.from(@_slot.querySelectorAll('[data-value]') or [])
|
|
22
|
+
|
|
23
|
+
_isChecked: (val) ->
|
|
24
|
+
Array.isArray(@value) and val in @value
|
|
25
|
+
|
|
26
|
+
_toggle: (val) ->
|
|
27
|
+
return if @disabled
|
|
28
|
+
arr = if Array.isArray(@value) then [...@value] else []
|
|
29
|
+
if val in arr
|
|
30
|
+
arr = arr.filter (v) -> v isnt val
|
|
31
|
+
else
|
|
32
|
+
arr.push val
|
|
33
|
+
@value = arr
|
|
34
|
+
@emit 'change', @value
|
|
35
|
+
|
|
36
|
+
onKeydown: (e) ->
|
|
37
|
+
boxes = @_root?.querySelectorAll('[role="checkbox"]')
|
|
38
|
+
return unless boxes?.length
|
|
39
|
+
focused = Array.from(boxes).indexOf(document.activeElement)
|
|
40
|
+
return if focused < 0
|
|
41
|
+
len = boxes.length
|
|
42
|
+
switch e.key
|
|
43
|
+
when 'ArrowDown', 'ArrowRight'
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
boxes[(focused + 1) %% len]?.focus()
|
|
46
|
+
when 'ArrowUp', 'ArrowLeft'
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
boxes[(focused - 1) %% len]?.focus()
|
|
49
|
+
|
|
50
|
+
render
|
|
51
|
+
div ref: "_root", role: "group", aria-label: @label or undefined, aria-orientation: @orientation
|
|
52
|
+
$orientation: @orientation
|
|
53
|
+
$disabled: @disabled?!
|
|
54
|
+
|
|
55
|
+
. ref: "_slot", style: "display:none"
|
|
56
|
+
slot
|
|
57
|
+
|
|
58
|
+
for opt, idx in _options
|
|
59
|
+
button role: "checkbox", tabindex: (if idx is 0 then "0" else "-1")
|
|
60
|
+
aria-checked: !!@_isChecked(opt.dataset.value)
|
|
61
|
+
$checked: @_isChecked(opt.dataset.value)?!
|
|
62
|
+
$disabled: @disabled?!
|
|
63
|
+
$value: opt.dataset.value
|
|
64
|
+
@click: (=> @_toggle(opt.dataset.value))
|
|
65
|
+
opt.textContent
|
package/checkbox.rip
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Checkbox — accessible headless checkbox/switch widget
|
|
2
|
+
#
|
|
3
|
+
# Toggles on click, Enter, or Space. Supports indeterminate state.
|
|
4
|
+
# Exposes $checked, $indeterminate, $disabled. Ships zero CSS.
|
|
5
|
+
# Set @switch to true for switch semantics (role="switch").
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Checkbox checked <=> isActive, @change: handleChange
|
|
9
|
+
# span "Enable notifications"
|
|
10
|
+
#
|
|
11
|
+
# Checkbox checked <=> isDark, switch: true
|
|
12
|
+
# span "Dark mode"
|
|
13
|
+
|
|
14
|
+
export Checkbox = component
|
|
15
|
+
@checked := false
|
|
16
|
+
@disabled := false
|
|
17
|
+
@indeterminate := false
|
|
18
|
+
@switch := false
|
|
19
|
+
|
|
20
|
+
onClick: ->
|
|
21
|
+
return if @disabled
|
|
22
|
+
@indeterminate = false
|
|
23
|
+
@checked = not @checked
|
|
24
|
+
@emit 'change', @checked
|
|
25
|
+
|
|
26
|
+
render
|
|
27
|
+
button role: @switch ? 'switch' : 'checkbox'
|
|
28
|
+
aria-checked: @indeterminate ? 'mixed' : !!@checked
|
|
29
|
+
aria-disabled: @disabled?!
|
|
30
|
+
$checked: @checked?!
|
|
31
|
+
$indeterminate: @indeterminate?!
|
|
32
|
+
$disabled: @disabled?!
|
|
33
|
+
slot
|
package/combobox.rip
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
# Combobox — accessible headless combobox (input + filterable listbox)
|
|
2
|
+
#
|
|
3
|
+
# Keyboard: ArrowDown/Up to navigate, Enter to select, Escape to close,
|
|
4
|
+
# typing filters the list via the @filter callback.
|
|
5
|
+
#
|
|
6
|
+
# Exposes $open on the wrapper, $highlighted on options.
|
|
7
|
+
# Ships zero CSS — style entirely via attribute selectors in your stylesheet.
|
|
8
|
+
#
|
|
9
|
+
# Usage:
|
|
10
|
+
# Combobox query <=> searchText, items: fruits, @select: handleSelect, @filter: filterFn
|
|
11
|
+
|
|
12
|
+
export Combobox = component
|
|
13
|
+
@query := ''
|
|
14
|
+
@items := []
|
|
15
|
+
@placeholder := 'Search...'
|
|
16
|
+
@disabled := false
|
|
17
|
+
@autoHighlight := true
|
|
18
|
+
|
|
19
|
+
open := false
|
|
20
|
+
highlightedIndex := -1
|
|
21
|
+
_listId =! "cb-#{Math.random().toString(36).slice(2, 8)}"
|
|
22
|
+
|
|
23
|
+
getItems: ->
|
|
24
|
+
return [] unless @_list
|
|
25
|
+
Array.from(@_list.querySelectorAll('[role="option"]'))
|
|
26
|
+
|
|
27
|
+
_scrollToItem: ->
|
|
28
|
+
@getItems()[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
|
|
29
|
+
|
|
30
|
+
clear: ->
|
|
31
|
+
@query = ''
|
|
32
|
+
highlightedIndex = -1
|
|
33
|
+
@emit 'filter', ''
|
|
34
|
+
|
|
35
|
+
onInput: (e) ->
|
|
36
|
+
@query = e.target.value
|
|
37
|
+
open = true
|
|
38
|
+
highlightedIndex = if @autoHighlight and @items.length > 0 then 0 else -1
|
|
39
|
+
@emit 'filter', @query
|
|
40
|
+
|
|
41
|
+
onFocusin: -> @openMenu()
|
|
42
|
+
|
|
43
|
+
onFocusout: (e) ->
|
|
44
|
+
unless @_content?.contains(e.relatedTarget)
|
|
45
|
+
@close()
|
|
46
|
+
|
|
47
|
+
openMenu: ->
|
|
48
|
+
open = true
|
|
49
|
+
highlightedIndex = -1
|
|
50
|
+
setTimeout => @_position(), 0
|
|
51
|
+
|
|
52
|
+
close: ->
|
|
53
|
+
open = false
|
|
54
|
+
highlightedIndex = -1
|
|
55
|
+
|
|
56
|
+
_position: ->
|
|
57
|
+
return unless @_input and @_list
|
|
58
|
+
tr = @_input.getBoundingClientRect()
|
|
59
|
+
@_list.style.left = "#{tr.left}px"
|
|
60
|
+
@_list.style.top = "#{tr.bottom + 2}px"
|
|
61
|
+
@_list.style.minWidth = "#{tr.width}px"
|
|
62
|
+
fl = @_list.getBoundingClientRect()
|
|
63
|
+
if fl.bottom > window.innerHeight
|
|
64
|
+
@_list.style.top = "#{tr.top - fl.height - 2}px"
|
|
65
|
+
|
|
66
|
+
isDisabled: (item) -> item?.hasAttribute?('data-disabled')
|
|
67
|
+
|
|
68
|
+
selectIndex: (idx) ->
|
|
69
|
+
item = @getItems()[idx]
|
|
70
|
+
return unless item
|
|
71
|
+
return if @isDisabled(item)
|
|
72
|
+
val = item.dataset.value ?? item.textContent.trim()
|
|
73
|
+
@emit 'select', val
|
|
74
|
+
@close()
|
|
75
|
+
|
|
76
|
+
_nextEnabled: (from, dir) ->
|
|
77
|
+
opts = @getItems()
|
|
78
|
+
len = opts.length
|
|
79
|
+
i = from
|
|
80
|
+
loop len
|
|
81
|
+
i = (i + dir) %% len
|
|
82
|
+
return i unless @isDisabled(opts[i])
|
|
83
|
+
from
|
|
84
|
+
|
|
85
|
+
_onKeydown: (e) ->
|
|
86
|
+
len = @getItems().length
|
|
87
|
+
switch e.key
|
|
88
|
+
when 'ArrowDown'
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
@openMenu() unless open
|
|
91
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, 1)
|
|
92
|
+
@_scrollToItem()
|
|
93
|
+
when 'ArrowUp'
|
|
94
|
+
e.preventDefault()
|
|
95
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, -1)
|
|
96
|
+
@_scrollToItem()
|
|
97
|
+
when 'Enter'
|
|
98
|
+
e.preventDefault()
|
|
99
|
+
if highlightedIndex >= 0
|
|
100
|
+
@selectIndex(highlightedIndex)
|
|
101
|
+
else if len is 1
|
|
102
|
+
@selectIndex(0)
|
|
103
|
+
when 'Escape'
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
if open then @close() else @query = ''
|
|
106
|
+
when 'Tab'
|
|
107
|
+
@close()
|
|
108
|
+
|
|
109
|
+
~>
|
|
110
|
+
if open
|
|
111
|
+
onDown = (e) =>
|
|
112
|
+
root = @_content
|
|
113
|
+
unless root?.contains(e.target)
|
|
114
|
+
@close()
|
|
115
|
+
document.addEventListener 'mousedown', onDown
|
|
116
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
117
|
+
|
|
118
|
+
render
|
|
119
|
+
. ref: "_content", $open: open?!
|
|
120
|
+
|
|
121
|
+
# Text input
|
|
122
|
+
. style: "position:relative;display:inline-flex;align-items:center"
|
|
123
|
+
input ref: "_input", role: "combobox"
|
|
124
|
+
type: "text"
|
|
125
|
+
autocomplete: "off"
|
|
126
|
+
aria-expanded: !!open
|
|
127
|
+
aria-haspopup: "listbox"
|
|
128
|
+
aria-autocomplete: "list"
|
|
129
|
+
aria-controls: if open then _listId else undefined
|
|
130
|
+
aria-activedescendant: if highlightedIndex >= 0 then "#{_listId}-#{highlightedIndex}" else undefined
|
|
131
|
+
$disabled: @disabled?!
|
|
132
|
+
disabled: @disabled
|
|
133
|
+
placeholder: @placeholder
|
|
134
|
+
value: @query
|
|
135
|
+
@keydown: @_onKeydown
|
|
136
|
+
if @query
|
|
137
|
+
button aria-label: "Clear", $clear: true, @click: @clear
|
|
138
|
+
"✕"
|
|
139
|
+
|
|
140
|
+
# Listbox — conditionally rendered (like Select)
|
|
141
|
+
if open and @items.length > 0
|
|
142
|
+
div ref: "_list", id: _listId, role: "listbox", $open: true
|
|
143
|
+
style: "position:fixed"
|
|
144
|
+
for item, idx in @items
|
|
145
|
+
div role: "option", tabindex: "-1", id: "#{_listId}-#{idx}"
|
|
146
|
+
$value: item
|
|
147
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
148
|
+
@click: (=> @selectIndex(idx))
|
|
149
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
150
|
+
"#{item}"
|
|
151
|
+
if open and @items.length is 0 and @query
|
|
152
|
+
div role: "status", aria-live: "polite", $empty: true
|
|
153
|
+
"No results"
|
package/context-menu.rip
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# ContextMenu — accessible headless right-click menu
|
|
2
|
+
#
|
|
3
|
+
# Opens on contextmenu event (right-click) over the trigger area.
|
|
4
|
+
# Keyboard navigation matches Menu. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ContextMenu @select: handleAction
|
|
8
|
+
# div $trigger: true
|
|
9
|
+
# p "Right-click this area"
|
|
10
|
+
# div $item: "cut", "Cut"
|
|
11
|
+
# div $item: "copy", "Copy"
|
|
12
|
+
# div $item: "paste", "Paste"
|
|
13
|
+
|
|
14
|
+
export ContextMenu = component
|
|
15
|
+
@disabled := false
|
|
16
|
+
|
|
17
|
+
open := false
|
|
18
|
+
highlightedIndex := -1
|
|
19
|
+
posX := 0
|
|
20
|
+
posY := 0
|
|
21
|
+
|
|
22
|
+
_menuItems ~=
|
|
23
|
+
return [] unless @_slot
|
|
24
|
+
Array.from(@_slot.querySelectorAll('[data-item]') or [])
|
|
25
|
+
|
|
26
|
+
_onContextMenu: (e) ->
|
|
27
|
+
return if @disabled
|
|
28
|
+
e.preventDefault()
|
|
29
|
+
posX = e.clientX
|
|
30
|
+
posY = e.clientY
|
|
31
|
+
open = true
|
|
32
|
+
highlightedIndex = 0
|
|
33
|
+
requestAnimationFrame =>
|
|
34
|
+
@_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
|
|
35
|
+
|
|
36
|
+
close: ->
|
|
37
|
+
open = false
|
|
38
|
+
highlightedIndex = -1
|
|
39
|
+
|
|
40
|
+
selectIndex: (idx) ->
|
|
41
|
+
item = _menuItems[idx]
|
|
42
|
+
return unless item
|
|
43
|
+
return if item.dataset.disabled?
|
|
44
|
+
@emit 'select', item.dataset.item
|
|
45
|
+
@close()
|
|
46
|
+
|
|
47
|
+
_onKeydown: (e) ->
|
|
48
|
+
len = _menuItems.length
|
|
49
|
+
return unless len
|
|
50
|
+
switch e.key
|
|
51
|
+
when 'ArrowDown'
|
|
52
|
+
e.preventDefault()
|
|
53
|
+
highlightedIndex = (highlightedIndex + 1) %% len
|
|
54
|
+
@_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
|
|
55
|
+
when 'ArrowUp'
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
highlightedIndex = (highlightedIndex - 1) %% len
|
|
58
|
+
@_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
|
|
59
|
+
when 'Home'
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
highlightedIndex = 0
|
|
62
|
+
@_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
|
|
63
|
+
when 'End'
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
highlightedIndex = len - 1
|
|
66
|
+
@_list?.querySelectorAll('[role="menuitem"]')[len - 1]?.focus()
|
|
67
|
+
when 'Enter', ' '
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
@selectIndex(highlightedIndex)
|
|
70
|
+
when 'Escape', 'Tab'
|
|
71
|
+
e.preventDefault() if e.key is 'Escape'
|
|
72
|
+
@close()
|
|
73
|
+
|
|
74
|
+
~>
|
|
75
|
+
if open
|
|
76
|
+
onDown = (e) =>
|
|
77
|
+
unless @_list?.contains(e.target)
|
|
78
|
+
@close()
|
|
79
|
+
document.addEventListener 'mousedown', onDown
|
|
80
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
81
|
+
|
|
82
|
+
render
|
|
83
|
+
. @contextmenu: @_onContextMenu
|
|
84
|
+
|
|
85
|
+
. ref: "_slot", style: "display:none"
|
|
86
|
+
slot
|
|
87
|
+
|
|
88
|
+
if open
|
|
89
|
+
div ref: "_list", role: "menu", $open: true, @keydown: @_onKeydown
|
|
90
|
+
style: "position:fixed;left:#{posX}px;top:#{posY}px;z-index:50"
|
|
91
|
+
for item, idx in _menuItems
|
|
92
|
+
div role: "menuitem", tabindex: "-1"
|
|
93
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
94
|
+
$disabled: item.dataset.disabled?!
|
|
95
|
+
$value: item.dataset.item
|
|
96
|
+
@click: (=> @selectIndex(idx))
|
|
97
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
98
|
+
item.textContent
|