@rip-lang/ui 0.3.19 → 0.3.21

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 (61) hide show
  1. package/README.md +443 -576
  2. package/accordion.rip +113 -0
  3. package/alert-dialog.rip +96 -0
  4. package/autocomplete.rip +141 -0
  5. package/avatar.rip +37 -0
  6. package/badge.rip +15 -0
  7. package/breadcrumb.rip +46 -0
  8. package/button-group.rip +26 -0
  9. package/button.rip +23 -0
  10. package/card.rip +25 -0
  11. package/carousel.rip +110 -0
  12. package/checkbox-group.rip +65 -0
  13. package/checkbox.rip +33 -0
  14. package/collapsible.rip +50 -0
  15. package/combobox.rip +155 -0
  16. package/context-menu.rip +105 -0
  17. package/date-picker.rip +214 -0
  18. package/dialog.rip +107 -0
  19. package/drawer.rip +79 -0
  20. package/editable-value.rip +80 -0
  21. package/field.rip +53 -0
  22. package/fieldset.rip +22 -0
  23. package/form.rip +39 -0
  24. package/grid.rip +901 -0
  25. package/index.rip +16 -0
  26. package/input-group.rip +28 -0
  27. package/input.rip +36 -0
  28. package/label.rip +16 -0
  29. package/menu.rip +162 -0
  30. package/menubar.rip +155 -0
  31. package/meter.rip +36 -0
  32. package/multi-select.rip +158 -0
  33. package/native-select.rip +32 -0
  34. package/nav-menu.rip +129 -0
  35. package/number-field.rip +162 -0
  36. package/otp-field.rip +89 -0
  37. package/package.json +18 -27
  38. package/pagination.rip +123 -0
  39. package/popover.rip +143 -0
  40. package/preview-card.rip +73 -0
  41. package/progress.rip +25 -0
  42. package/radio-group.rip +67 -0
  43. package/resizable.rip +123 -0
  44. package/scroll-area.rip +145 -0
  45. package/select.rip +184 -0
  46. package/separator.rip +17 -0
  47. package/skeleton.rip +22 -0
  48. package/slider.rip +165 -0
  49. package/spinner.rip +17 -0
  50. package/table.rip +27 -0
  51. package/tabs.rip +124 -0
  52. package/textarea.rip +48 -0
  53. package/toast.rip +87 -0
  54. package/toggle-group.rip +78 -0
  55. package/toggle.rip +24 -0
  56. package/toolbar.rip +46 -0
  57. package/tooltip.rip +115 -0
  58. package/dist/rip-ui.min.js +0 -524
  59. package/dist/rip-ui.min.js.br +0 -0
  60. package/serve.rip +0 -92
  61. package/ui.rip +0 -964
package/index.rip ADDED
@@ -0,0 +1,16 @@
1
+ # Widget Gallery — dev server for testing all widgets
2
+
3
+ import { get, use, start, notFound } from '@rip-lang/server'
4
+ import { serve } from '@rip-lang/server/middleware'
5
+
6
+ dir = import.meta.dir
7
+
8
+ use serve dir: dir, components: ['.'], watch: true
9
+
10
+ get '/*.rip', -> @send "#{dir}/#{@req.path.slice(1)}"
11
+ get '/*.css', -> @send "#{dir}/#{@req.path.slice(1)}", 'text/css; charset=UTF-8'
12
+ get '/hljs-rip.js', -> @send "#{dir}/../print/hljs-rip.js", 'text/javascript; charset=UTF-8'
13
+
14
+ notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
15
+
16
+ start port: 3005
@@ -0,0 +1,28 @@
1
+ # InputGroup — accessible headless input with prefix/suffix
2
+ #
3
+ # Wraps a form control with optional prefix and suffix elements.
4
+ # Use $prefix and $suffix on children to mark addon positions.
5
+ # Tracks child input focus for styling. Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # InputGroup
9
+ # span $prefix: true, "$"
10
+ # Input value <=> amount, type: "number"
11
+ # InputGroup
12
+ # Input value <=> search, placeholder: "Search..."
13
+ # button $suffix: true, @click: doSearch, "Go"
14
+
15
+ export InputGroup = component
16
+ @disabled := false
17
+
18
+ focused := false
19
+
20
+ mounted: ->
21
+ ctrl = @_root?.querySelector('input, select, textarea')
22
+ return unless ctrl
23
+ ctrl.addEventListener 'focusin', => focused = true
24
+ ctrl.addEventListener 'focusout', => focused = false
25
+
26
+ render
27
+ div ref: "_root", $disabled: @disabled?!, $focused: focused?!
28
+ slot
package/input.rip ADDED
@@ -0,0 +1,36 @@
1
+ # Input — accessible headless input wrapper
2
+ #
3
+ # Tracks focus, validation, and disabled state via data attributes.
4
+ # Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Input value <=> name, placeholder: "Enter name"
8
+ # Input value <=> email, type: "email", required: true
9
+
10
+ export Input = component
11
+ @value := ''
12
+ @placeholder := ''
13
+ @type := 'text'
14
+ @disabled := false
15
+ @required := false
16
+
17
+ focused := false
18
+ touched := false
19
+
20
+ onInput: (e) -> @value = e.target.value
21
+ onFocus: -> focused = true
22
+ onBlur: ->
23
+ focused = false
24
+ touched = true
25
+
26
+ render
27
+ input type: @type, value: @value, placeholder: @placeholder
28
+ disabled: @disabled
29
+ required: @required
30
+ aria-disabled: @disabled?!
31
+ aria-required: @required?!
32
+ $disabled: @disabled?!
33
+ $focused: focused?!
34
+ $touched: touched?!
35
+ @focusin: @onFocus
36
+ @focusout: @onBlur
package/label.rip ADDED
@@ -0,0 +1,16 @@
1
+ # Label — accessible headless form label
2
+ #
3
+ # Standalone label element that associates with a form control via @for.
4
+ # Companions Field but works independently. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Label for: "email-input", "Email address"
8
+ # Label required: true, "Username"
9
+
10
+ export Label = component
11
+ @for := null
12
+ @required := false
13
+
14
+ render
15
+ label for: @for?!, $required: @required?!
16
+ slot
package/menu.rip ADDED
@@ -0,0 +1,162 @@
1
+ # Menu — accessible headless dropdown menu
2
+ #
3
+ # Keyboard: ArrowDown/Up to navigate, Enter/Space to select, Escape to close,
4
+ # Home/End for first/last. Exposes $open on menu, $highlighted on items.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Menu
9
+ # span "Actions"
10
+ # div $item: "edit", "Edit"
11
+ # div $item: "delete", "Delete"
12
+ # div $item: "archive", "Archive"
13
+
14
+ export Menu = component
15
+ @disabled := false
16
+
17
+ open := false
18
+ highlightedIndex := -1
19
+ typeaheadBuffer := ''
20
+ typeaheadTimer := null
21
+
22
+ items ~=
23
+ return [] unless @_slot
24
+ Array.from(@_slot.querySelectorAll('[data-item]') or [])
25
+
26
+ triggerLabel ~=
27
+ return '' unless @_slot
28
+ el = @_slot.querySelector(':not([data-item])')
29
+ el?.textContent?.trim() or ''
30
+
31
+ toggle: ->
32
+ return if @disabled
33
+ if open then @close() else @openMenu()
34
+
35
+ openMenu: ->
36
+ open = true
37
+ highlightedIndex = 0
38
+ requestAnimationFrame =>
39
+ @_position()
40
+ @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
41
+
42
+ close: ->
43
+ open = false
44
+ highlightedIndex = -1
45
+ @_trigger?.focus()
46
+
47
+ selectIndex: (idx) ->
48
+ item = items[idx]
49
+ return unless item
50
+ return if item.dataset.disabled?
51
+ role = item.getAttribute('role')
52
+ if role is 'menuitemcheckbox'
53
+ checked = item.getAttribute('aria-checked') is 'true'
54
+ item.setAttribute 'aria-checked', not checked
55
+ @emit 'select', { id: item.dataset.item, checked: not checked }
56
+ else if role is 'menuitemradio'
57
+ group = item.closest('[data-radio-group]')
58
+ if group
59
+ group.querySelectorAll('[role="menuitemradio"]').forEach (r) ->
60
+ r.setAttribute 'aria-checked', false
61
+ item.setAttribute 'aria-checked', true
62
+ @emit 'select', { id: item.dataset.item, value: item.dataset.item }
63
+ else
64
+ @emit 'select', item.dataset.item
65
+ @close()
66
+
67
+ _typeahead: (char) ->
68
+ clearTimeout typeaheadTimer if typeaheadTimer
69
+ typeaheadBuffer += char.toLowerCase()
70
+ typeaheadTimer = setTimeout (-> typeaheadBuffer = ''), 500
71
+ idx = items.findIndex (o) -> o.textContent.trim().toLowerCase().startsWith(typeaheadBuffer)
72
+ if idx >= 0
73
+ highlightedIndex = idx
74
+ @_list?.querySelectorAll('[role="menuitem"]')[idx]?.focus()
75
+
76
+ _position: ->
77
+ return unless @_trigger and @_list
78
+ tr = @_trigger.getBoundingClientRect()
79
+ @_list.style.position = 'fixed'
80
+ @_list.style.left = "#{tr.left}px"
81
+ @_list.style.top = "#{tr.bottom + 4}px"
82
+ @_list.style.minWidth = "#{tr.width}px"
83
+ fl = @_list.getBoundingClientRect()
84
+ if fl.bottom > window.innerHeight
85
+ @_list.style.top = "#{tr.top - fl.height - 4}px"
86
+ if fl.right > window.innerWidth
87
+ @_list.style.left = "#{window.innerWidth - fl.width - 4}px"
88
+ @_list.style.visibility = 'visible'
89
+
90
+ onTriggerKeydown: (e) ->
91
+ return if @disabled
92
+ if e.key in ['ArrowDown', 'Enter', ' ']
93
+ e.preventDefault()
94
+ @openMenu()
95
+
96
+ onMenuKeydown: (e) ->
97
+ len = items.length
98
+ return unless len
99
+
100
+ switch e.key
101
+ when 'ArrowDown'
102
+ e.preventDefault()
103
+ highlightedIndex = (highlightedIndex + 1) %% len
104
+ @_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
105
+ when 'ArrowUp'
106
+ e.preventDefault()
107
+ highlightedIndex = (highlightedIndex - 1) %% len
108
+ @_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
109
+ when 'Home'
110
+ e.preventDefault()
111
+ highlightedIndex = 0
112
+ @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
113
+ when 'End'
114
+ e.preventDefault()
115
+ highlightedIndex = len - 1
116
+ @_list?.querySelectorAll('[role="menuitem"]')[len - 1]?.focus()
117
+ when 'Enter', ' '
118
+ e.preventDefault()
119
+ @selectIndex(highlightedIndex)
120
+ when 'Escape'
121
+ e.preventDefault()
122
+ @close()
123
+ when 'Tab'
124
+ @close()
125
+ else
126
+ if e.key.length is 1
127
+ @_typeahead(e.key)
128
+
129
+ ~>
130
+ if open
131
+ onDown = (e) =>
132
+ unless @_trigger?.contains(e.target) or @_list?.contains(e.target)
133
+ @close()
134
+ document.addEventListener 'mousedown', onDown
135
+ return -> document.removeEventListener 'mousedown', onDown
136
+
137
+ render
138
+ .
139
+ button ref: "_trigger"
140
+ aria-haspopup: "menu"
141
+ aria-expanded: !!open
142
+ $open: open?!
143
+ $disabled: @disabled?!
144
+ disabled: @disabled
145
+ @click: @toggle
146
+ @keydown: @onTriggerKeydown
147
+ triggerLabel
148
+
149
+ . ref: "_slot", style: "display:none"
150
+ slot
151
+
152
+ if open
153
+ div ref: "_list", role: "menu", $open: true, style: "position:fixed;visibility:hidden", @keydown: @onMenuKeydown
154
+ for item, idx in items
155
+ div role: item.getAttribute('role') or 'menuitem'
156
+ tabindex: "-1"
157
+ aria-checked: item.getAttribute('aria-checked')?!
158
+ $highlighted: (idx is highlightedIndex)?!
159
+ $disabled: item.dataset.disabled?!
160
+ @click: (=> @selectIndex(idx))
161
+ @mouseenter: (=> highlightedIndex = idx)
162
+ = item.textContent
package/menubar.rip ADDED
@@ -0,0 +1,155 @@
1
+ # Menubar — accessible headless horizontal menu bar
2
+ #
3
+ # A horizontal bar of menu triggers. Each trigger opens a dropdown menu.
4
+ # Arrow keys navigate between triggers; open menus close when moving
5
+ # to the next trigger. Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Menubar
9
+ # div $menu: "file"
10
+ # div $item: "new", "New"
11
+ # div $item: "open", "Open"
12
+ # div $item: "save", "Save"
13
+ # div $menu: "edit"
14
+ # div $item: "undo", "Undo"
15
+ # div $item: "redo", "Redo"
16
+
17
+ export Menubar = component
18
+ @disabled := false
19
+
20
+ activeMenu := null
21
+ highlightedIndex := -1
22
+
23
+ _menus ~=
24
+ return [] unless @_slot
25
+ Array.from(@_slot.querySelectorAll('[data-menu]') or [])
26
+
27
+ _menuItemsFor: (menu) ->
28
+ return [] unless menu
29
+ Array.from(menu.querySelectorAll('[data-item]') or [])
30
+
31
+ _openMenu: (menuId) ->
32
+ return if @disabled
33
+ activeMenu = menuId
34
+ highlightedIndex = 0
35
+ requestAnimationFrame =>
36
+ @_position(menuId)
37
+ @_root?.querySelector("[data-menu-list=\"#{menuId}\"] [role=\"menuitem\"]")?.focus()
38
+
39
+ _closeMenu: ->
40
+ activeMenu = null
41
+ highlightedIndex = -1
42
+
43
+ _position: (menuId) ->
44
+ trigger = @_root?.querySelector("[data-menu-trigger=\"#{menuId}\"]")
45
+ list = @_root?.querySelector("[data-menu-list=\"#{menuId}\"]")
46
+ return unless trigger and list
47
+ tr = trigger.getBoundingClientRect()
48
+ list.style.position = 'fixed'
49
+ list.style.left = "#{tr.left}px"
50
+ list.style.top = "#{tr.bottom + 2}px"
51
+ list.style.minWidth = "#{tr.width}px"
52
+
53
+ selectItem: (menuId, itemId) ->
54
+ @emit 'select', { menu: menuId, item: itemId }
55
+ @_closeMenu()
56
+ @_root?.querySelector("[data-menu-trigger=\"#{menuId}\"]")?.focus()
57
+
58
+ _onBarKeydown: (e) ->
59
+ triggers = @_root?.querySelectorAll('[data-menu-trigger]')
60
+ return unless triggers?.length
61
+ focused = Array.from(triggers).indexOf(document.activeElement)
62
+ return if focused < 0
63
+ len = triggers.length
64
+ switch e.key
65
+ when 'ArrowRight'
66
+ e.preventDefault()
67
+ next = (focused + 1) %% len
68
+ triggers[next]?.focus()
69
+ if activeMenu
70
+ @_openMenu(triggers[next]?.dataset.menuTrigger)
71
+ when 'ArrowLeft'
72
+ e.preventDefault()
73
+ prev = (focused - 1) %% len
74
+ triggers[prev]?.focus()
75
+ if activeMenu
76
+ @_openMenu(triggers[prev]?.dataset.menuTrigger)
77
+ when 'ArrowDown', 'Enter', ' '
78
+ e.preventDefault()
79
+ menuId = triggers[focused]?.dataset.menuTrigger
80
+ @_openMenu(menuId) if menuId
81
+ when 'Escape'
82
+ @_closeMenu()
83
+
84
+ _onMenuKeydown: (e, menuId, menuItems) ->
85
+ len = menuItems.length
86
+ return unless len
87
+ switch e.key
88
+ when 'ArrowDown'
89
+ e.preventDefault()
90
+ highlightedIndex = (highlightedIndex + 1) %% len
91
+ @_root?.querySelector("[data-menu-list=\"#{menuId}\"]")?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
92
+ when 'ArrowUp'
93
+ e.preventDefault()
94
+ highlightedIndex = (highlightedIndex - 1) %% len
95
+ @_root?.querySelector("[data-menu-list=\"#{menuId}\"]")?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
96
+ when 'Enter', ' '
97
+ e.preventDefault()
98
+ item = menuItems[highlightedIndex]
99
+ @selectItem(menuId, item?.dataset.item) if item
100
+ when 'Escape', 'Tab'
101
+ e.preventDefault() if e.key is 'Escape'
102
+ @_closeMenu()
103
+ @_root?.querySelector("[data-menu-trigger=\"#{menuId}\"]")?.focus()
104
+ when 'ArrowRight'
105
+ e.preventDefault()
106
+ @_closeMenu()
107
+ triggers = @_root?.querySelectorAll('[data-menu-trigger]')
108
+ focused = Array.from(triggers).findIndex (t) -> t.dataset.menuTrigger is menuId
109
+ next = (focused + 1) %% triggers.length
110
+ triggers[next]?.focus()
111
+ @_openMenu(triggers[next]?.dataset.menuTrigger)
112
+ when 'ArrowLeft'
113
+ e.preventDefault()
114
+ @_closeMenu()
115
+ triggers = @_root?.querySelectorAll('[data-menu-trigger]')
116
+ focused = Array.from(triggers).findIndex (t) -> t.dataset.menuTrigger is menuId
117
+ prev = (focused - 1) %% triggers.length
118
+ triggers[prev]?.focus()
119
+ @_openMenu(triggers[prev]?.dataset.menuTrigger)
120
+
121
+ ~>
122
+ if activeMenu
123
+ onDown = (e) =>
124
+ unless @_root?.contains(e.target)
125
+ @_closeMenu()
126
+ document.addEventListener 'mousedown', onDown
127
+ return -> document.removeEventListener 'mousedown', onDown
128
+
129
+ render
130
+ div ref: "_root", role: "menubar", $disabled: @disabled?!
131
+
132
+ . ref: "_slot", style: "display:none"
133
+ slot
134
+
135
+ for menu in _menus
136
+ button role: "menuitem", tabindex: "0"
137
+ "data-menu-trigger": menu.dataset.menu
138
+ aria-haspopup: "menu"
139
+ aria-expanded: activeMenu is menu.dataset.menu
140
+ $open: (activeMenu is menu.dataset.menu)?!
141
+ @click: (=> if activeMenu is menu.dataset.menu then @_closeMenu() else @_openMenu(menu.dataset.menu))
142
+ @keydown: @_onBarKeydown
143
+ = menu.dataset.menu
144
+
145
+ if activeMenu is menu.dataset.menu
146
+ div role: "menu", $open: true, style: "position:fixed"
147
+ "data-menu-list": menu.dataset.menu
148
+ @keydown: (e) => @_onMenuKeydown(e, menu.dataset.menu, @_menuItemsFor(menu))
149
+ for item, idx in @_menuItemsFor(menu)
150
+ div role: "menuitem", tabindex: "-1"
151
+ $highlighted: (idx is highlightedIndex)?!
152
+ $value: item.dataset.item
153
+ @click: (=> @selectItem(menu.dataset.menu, item.dataset.item))
154
+ @mouseenter: (=> highlightedIndex = idx)
155
+ = item.textContent
package/meter.rip ADDED
@@ -0,0 +1,36 @@
1
+ # Meter — accessible headless meter (gauge)
2
+ #
3
+ # For known-range measurements (disk usage, password strength, etc.).
4
+ # Exposes value and thresholds as CSS custom properties.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Meter value: 0.7, low: 0.3, high: 0.8, optimum: 0.5
9
+ # Meter value: 75, min: 0, max: 100
10
+
11
+ export Meter = component
12
+ @value := 0
13
+ @min := 0
14
+ @max := 1
15
+ @low := null
16
+ @high := null
17
+ @optimum := null
18
+ @label := null
19
+
20
+ percent ~= Math.min(100, Math.max(0, ((@value - @min) / (@max - @min)) * 100))
21
+
22
+ level ~=
23
+ return 'optimum' unless @low? and @high?
24
+ if @value <= @low then 'low'
25
+ else if @value >= @high then 'high'
26
+ else 'optimum'
27
+
28
+ render
29
+ div role: "meter"
30
+ aria-valuenow: @value
31
+ aria-valuemin: @min
32
+ aria-valuemax: @max
33
+ aria-label: @label?!
34
+ style: "--meter-value: #{@value}; --meter-percent: #{percent}%"
35
+ $level: level
36
+ slot
@@ -0,0 +1,158 @@
1
+ # MultiSelect — accessible headless multi-select with chips
2
+ #
3
+ # Filterable dropdown where multiple items can be selected. Selected items
4
+ # appear as removable chips. Type to filter, click or arrow+Enter to toggle.
5
+ # Ships zero CSS — style entirely via attribute selectors in your stylesheet.
6
+ #
7
+ # Usage:
8
+ # MultiSelect value <=> selectedColors, items: colors, placeholder: "Choose colors..."
9
+
10
+ export MultiSelect = component
11
+ @value := []
12
+ @items := []
13
+ @placeholder := 'Select...'
14
+ @disabled := false
15
+
16
+ open := false
17
+ query := ''
18
+ highlightedIndex := -1
19
+ _listId =! "ms-#{Math.random().toString(36).slice(2, 8)}"
20
+
21
+ filtered ~=
22
+ q = query.trim().toLowerCase()
23
+ return @items unless q
24
+ @items.filter (item) ->
25
+ label = if typeof item is 'string' then item else (item.label or item.name or String(item))
26
+ label.toLowerCase().includes(q)
27
+
28
+ _label: (item) ->
29
+ if typeof item is 'string' then item else (item.label or item.name or String(item))
30
+
31
+ _val: (item) ->
32
+ if typeof item is 'string' then item else (item.value or item.id or String(item))
33
+
34
+ _isSelected: (item) ->
35
+ v = @_val(item)
36
+ Array.isArray(@value) and v in @value
37
+
38
+ _toggleItem: (item) ->
39
+ return if @disabled
40
+ v = @_val(item)
41
+ arr = if Array.isArray(@value) then [...@value] else []
42
+ if v in arr
43
+ arr = arr.filter (x) -> x isnt v
44
+ else
45
+ arr.push v
46
+ @value = arr
47
+ @emit 'change', @value
48
+
49
+ _removeChip: (v) ->
50
+ return if @disabled
51
+ @value = @value.filter (x) -> x isnt v
52
+ @emit 'change', @value
53
+
54
+ clearAll: ->
55
+ return if @disabled
56
+ @value = []
57
+ query = ''
58
+ @emit 'change', @value
59
+
60
+ _onInput: (e) ->
61
+ query = e.target.value
62
+ open = true
63
+ highlightedIndex = if filtered.length > 0 then 0 else -1
64
+
65
+ onFocusin: -> open = true
66
+
67
+ onFocusout: (e) ->
68
+ unless @_content?.contains(e.relatedTarget)
69
+ open = false
70
+ query = ''
71
+ highlightedIndex = -1
72
+
73
+ _position: ->
74
+ return unless @_input and @_list
75
+ tr = @_input.getBoundingClientRect()
76
+ @_list.style.left = "#{tr.left}px"
77
+ @_list.style.top = "#{tr.bottom + 2}px"
78
+ @_list.style.minWidth = "#{tr.width}px"
79
+ fl = @_list.getBoundingClientRect()
80
+ if fl.bottom > window.innerHeight
81
+ @_list.style.top = "#{tr.top - fl.height - 2}px"
82
+
83
+ _onKeydown: (e) ->
84
+ len = filtered.length
85
+ switch e.key
86
+ when 'ArrowDown'
87
+ e.preventDefault()
88
+ open = true
89
+ highlightedIndex = (highlightedIndex + 1) %% len if len
90
+ setTimeout (=> @_position()), 0
91
+ when 'ArrowUp'
92
+ e.preventDefault()
93
+ highlightedIndex = (highlightedIndex - 1) %% len if len
94
+ when 'Enter'
95
+ e.preventDefault()
96
+ if highlightedIndex >= 0 and highlightedIndex < len
97
+ @_toggleItem(filtered[highlightedIndex])
98
+ when 'Escape'
99
+ e.preventDefault()
100
+ open = false
101
+ query = ''
102
+ when 'Backspace'
103
+ if not query and @value.length > 0
104
+ @value = @value.slice(0, -1)
105
+ @emit 'change', @value
106
+ when 'Tab'
107
+ open = false
108
+ query = ''
109
+
110
+ ~>
111
+ if open
112
+ setTimeout (=> @_position()), 0
113
+ onDown = (e) =>
114
+ unless @_content?.contains(e.target)
115
+ open = false
116
+ query = ''
117
+ highlightedIndex = -1
118
+ document.addEventListener 'mousedown', onDown
119
+ return -> document.removeEventListener 'mousedown', onDown
120
+
121
+ render
122
+ . ref: "_content", $open: open?!, $disabled: @disabled?!
123
+
124
+ # Chip area + input
125
+ . $chips: true, @click: (=> @_input?.focus())
126
+ for chip in @value
127
+ span $chip: true
128
+ "#{chip}"
129
+ button $remove: true, aria-label: "Remove #{chip}", @click: (=> @_removeChip(chip))
130
+ "✕"
131
+ input ref: "_input", type: "text", autocomplete: "off"
132
+ role: "combobox"
133
+ aria-expanded: !!open
134
+ aria-haspopup: "listbox"
135
+ aria-controls: if open then _listId else undefined
136
+ $disabled: @disabled?!
137
+ disabled: @disabled
138
+ placeholder: if @value.length is 0 then @placeholder else ''
139
+ value: query
140
+ @input: @_onInput
141
+ @keydown: @_onKeydown
142
+ if @value.length > 0
143
+ button $clear: true, aria-label: "Clear all", @click: @clearAll
144
+ "✕"
145
+
146
+ # Dropdown
147
+ if open and filtered.length > 0
148
+ div ref: "_list", id: _listId, role: "listbox", $open: true, aria-multiselectable: "true"
149
+ style: "position:fixed"
150
+ for item, idx in filtered
151
+ div role: "option", tabindex: "-1", id: "#{_listId}-#{idx}"
152
+ $value: @_val(item)
153
+ $selected: @_isSelected(item)?!
154
+ $highlighted: (idx is highlightedIndex)?!
155
+ aria-selected: !!@_isSelected(item)
156
+ @click: (=> @_toggleItem(item))
157
+ @mouseenter: (=> highlightedIndex = idx)
158
+ @_label(item)
@@ -0,0 +1,32 @@
1
+ # NativeSelect — accessible headless native select wrapper
2
+ #
3
+ # Wraps a native <select> element with state tracking via data attributes.
4
+ # Use when the browser's built-in dropdown is preferred. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # NativeSelect value <=> role, @change: handleChange
8
+ # option value: "", "Choose a role..."
9
+ # option value: "admin", "Admin"
10
+ # option value: "user", "User"
11
+
12
+ export NativeSelect = component
13
+ @value := ''
14
+ @disabled := false
15
+ @required := false
16
+
17
+ focused := false
18
+
19
+ onChange: (e) ->
20
+ @value = e.target.value
21
+ @emit 'change', @value
22
+
23
+ render
24
+ select value: @value, disabled: @disabled, required: @required
25
+ aria-disabled: @disabled?!
26
+ aria-required: @required?!
27
+ $disabled: @disabled?!
28
+ $focused: focused?!
29
+ @change: @onChange
30
+ @focusin: (=> focused = true)
31
+ @focusout: (=> focused = false)
32
+ slot