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.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/docs/dist/rip.js +28 -8
  3. package/docs/dist/rip.min.js +16 -16
  4. package/docs/dist/rip.min.js.br +0 -0
  5. package/docs/index.html +4 -0
  6. package/docs/ui/accordion.rip +113 -0
  7. package/docs/ui/autocomplete.rip +141 -0
  8. package/docs/ui/avatar.rip +37 -0
  9. package/docs/ui/button.rip +23 -0
  10. package/docs/ui/checkbox-group.rip +65 -0
  11. package/docs/ui/checkbox.rip +33 -0
  12. package/docs/ui/combobox.rip +155 -0
  13. package/docs/ui/context-menu.rip +105 -0
  14. package/docs/ui/date-picker.rip +214 -0
  15. package/docs/ui/dialog.rip +107 -0
  16. package/docs/ui/drawer.rip +79 -0
  17. package/docs/ui/editable-value.rip +80 -0
  18. package/docs/ui/field.rip +53 -0
  19. package/docs/ui/fieldset.rip +22 -0
  20. package/docs/ui/form.rip +39 -0
  21. package/docs/ui/grid.rip +901 -0
  22. package/docs/ui/index.css +1379 -0
  23. package/docs/ui/index.html +2097 -0
  24. package/docs/ui/input.rip +36 -0
  25. package/docs/ui/menu.rip +162 -0
  26. package/docs/ui/menubar.rip +155 -0
  27. package/docs/ui/meter.rip +36 -0
  28. package/docs/ui/multi-select.rip +158 -0
  29. package/docs/ui/nav-menu.rip +129 -0
  30. package/docs/ui/number-field.rip +162 -0
  31. package/docs/ui/otp-field.rip +89 -0
  32. package/docs/ui/popover.rip +143 -0
  33. package/docs/ui/preview-card.rip +73 -0
  34. package/docs/ui/progress.rip +25 -0
  35. package/docs/ui/radio-group.rip +67 -0
  36. package/docs/ui/scroll-area.rip +145 -0
  37. package/docs/ui/select.rip +184 -0
  38. package/docs/ui/separator.rip +17 -0
  39. package/docs/ui/slider.rip +165 -0
  40. package/docs/ui/tabs.rip +124 -0
  41. package/docs/ui/toast.rip +87 -0
  42. package/docs/ui/toggle-group.rip +78 -0
  43. package/docs/ui/toggle.rip +24 -0
  44. package/docs/ui/toolbar.rip +46 -0
  45. package/docs/ui/tooltip.rip +115 -0
  46. package/package.json +2 -1
  47. package/src/app.rip +1 -1
  48. package/src/components.js +3 -4
  49. package/src/lexer.js +1 -1
Binary file
package/docs/index.html CHANGED
@@ -520,6 +520,7 @@
520
520
  <button class="tab" data-tab="sierpinski">Sierpinski</button>
521
521
  <button class="tab" data-tab="charts" title="Opens in new tab">Charts ⧉</button>
522
522
  <button class="tab" data-tab="demo" title="Opens in new tab">Demo ⧉</button>
523
+ <button class="tab" data-tab="ui" title="Opens in new tab">Rip UI ⧉</button>
523
524
  </div>
524
525
 
525
526
  <!-- Content -->
@@ -1136,6 +1137,9 @@
1136
1137
  if tabName is 'demo'
1137
1138
  window.open 'https://shreeve.github.io/rip-lang/results/', '_blank'
1138
1139
  return
1140
+ if tabName is 'ui'
1141
+ window.open 'ui/', '_blank'
1142
+ return
1139
1143
  window.location.hash = tabName
1140
1144
  document.querySelectorAll('.tab').forEach (t) -> t.classList.remove 'active'
1141
1145
  document.querySelector(".tab[data-tab='#{tabName}']").classList.add 'active'
@@ -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))}"
@@ -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
+ "?"
@@ -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
@@ -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
@@ -0,0 +1,155 @@
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
+ @query = val
74
+ @emit 'select', val
75
+ @close()
76
+ @_input?.blur()
77
+
78
+ _nextEnabled: (from, dir) ->
79
+ opts = @getItems()
80
+ len = opts.length
81
+ i = from
82
+ loop len
83
+ i = (i + dir) %% len
84
+ return i unless @isDisabled(opts[i])
85
+ from
86
+
87
+ _onKeydown: (e) ->
88
+ len = @getItems().length
89
+ switch e.key
90
+ when 'ArrowDown'
91
+ e.preventDefault()
92
+ @openMenu() unless open
93
+ highlightedIndex = @_nextEnabled(highlightedIndex, 1)
94
+ @_scrollToItem()
95
+ when 'ArrowUp'
96
+ e.preventDefault()
97
+ highlightedIndex = @_nextEnabled(highlightedIndex, -1)
98
+ @_scrollToItem()
99
+ when 'Enter'
100
+ e.preventDefault()
101
+ if highlightedIndex >= 0
102
+ @selectIndex(highlightedIndex)
103
+ else if len is 1
104
+ @selectIndex(0)
105
+ when 'Escape'
106
+ e.preventDefault()
107
+ if open then @close() else @query = ''
108
+ when 'Tab'
109
+ @close()
110
+
111
+ ~>
112
+ if open
113
+ onDown = (e) =>
114
+ root = @_content
115
+ unless root?.contains(e.target)
116
+ @close()
117
+ document.addEventListener 'mousedown', onDown
118
+ return -> document.removeEventListener 'mousedown', onDown
119
+
120
+ render
121
+ . ref: "_content", $open: open?!
122
+
123
+ # Text input
124
+ . style: "position:relative;display:inline-flex;align-items:center"
125
+ input ref: "_input", role: "combobox"
126
+ type: "text"
127
+ autocomplete: "off"
128
+ aria-expanded: !!open
129
+ aria-haspopup: "listbox"
130
+ aria-autocomplete: "list"
131
+ aria-controls: if open then _listId else undefined
132
+ aria-activedescendant: if highlightedIndex >= 0 then "#{_listId}-#{highlightedIndex}" else undefined
133
+ $disabled: @disabled?!
134
+ disabled: @disabled
135
+ placeholder: @placeholder
136
+ value: @query
137
+ @keydown: @_onKeydown
138
+ if @query
139
+ button aria-label: "Clear", $clear: true, @click: @clear
140
+ "✕"
141
+
142
+ # Listbox — conditionally rendered (like Select)
143
+ if open and @items.length > 0
144
+ div ref: "_list", id: _listId, role: "listbox", $open: true
145
+ style: "position:fixed"
146
+ for item, idx in @items
147
+ div role: "option", tabindex: "-1", id: "#{_listId}-#{idx}"
148
+ $value: item
149
+ $highlighted: (idx is highlightedIndex)?!
150
+ @click: (=> @selectIndex(idx))
151
+ @mouseenter: (=> highlightedIndex = idx)
152
+ "#{item}"
153
+ if open and @items.length is 0 and @query
154
+ div role: "status", aria-live: "polite", $empty: true
155
+ "No results"
@@ -0,0 +1,105 @@
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
+ _triggerEl ~=
27
+ return null unless @_slot
28
+ @_slot.querySelector('[data-trigger]')
29
+
30
+ _onContextMenu: (e) ->
31
+ return if @disabled
32
+ e.preventDefault()
33
+ posX = e.clientX
34
+ posY = e.clientY
35
+ open = true
36
+ highlightedIndex = 0
37
+ requestAnimationFrame =>
38
+ @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
39
+
40
+ close: ->
41
+ open = false
42
+ highlightedIndex = -1
43
+
44
+ selectIndex: (idx) ->
45
+ item = _menuItems[idx]
46
+ return unless item
47
+ return if item.dataset.disabled?
48
+ @emit 'select', item.dataset.item
49
+ @close()
50
+
51
+ _onKeydown: (e) ->
52
+ len = _menuItems.length
53
+ return unless len
54
+ switch e.key
55
+ when 'ArrowDown'
56
+ e.preventDefault()
57
+ highlightedIndex = (highlightedIndex + 1) %% len
58
+ @_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
59
+ when 'ArrowUp'
60
+ e.preventDefault()
61
+ highlightedIndex = (highlightedIndex - 1) %% len
62
+ @_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
63
+ when 'Home'
64
+ e.preventDefault()
65
+ highlightedIndex = 0
66
+ @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
67
+ when 'End'
68
+ e.preventDefault()
69
+ highlightedIndex = len - 1
70
+ @_list?.querySelectorAll('[role="menuitem"]')[len - 1]?.focus()
71
+ when 'Enter', ' '
72
+ e.preventDefault()
73
+ @selectIndex(highlightedIndex)
74
+ when 'Escape', 'Tab'
75
+ e.preventDefault() if e.key is 'Escape'
76
+ @close()
77
+
78
+ ~>
79
+ if open
80
+ onDown = (e) =>
81
+ unless @_list?.contains(e.target)
82
+ @close()
83
+ document.addEventListener 'mousedown', onDown
84
+ return -> document.removeEventListener 'mousedown', onDown
85
+
86
+ render
87
+ . @contextmenu: @_onContextMenu
88
+
89
+ . ref: "_slot", style: "display:none"
90
+ slot
91
+
92
+ if _triggerEl
93
+ . innerHTML: _triggerEl.innerHTML
94
+
95
+ if open
96
+ div ref: "_list", role: "menu", $open: true, @keydown: @_onKeydown
97
+ style: "position:fixed;left:#{posX}px;top:#{posY}px;z-index:50"
98
+ for item, idx in _menuItems
99
+ div role: "menuitem", tabindex: "-1"
100
+ $highlighted: (idx is highlightedIndex)?!
101
+ $disabled: item.dataset.disabled?!
102
+ $value: item.dataset.item
103
+ @click: (=> @selectIndex(idx))
104
+ @mouseenter: (=> highlightedIndex = idx)
105
+ = item.textContent