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