@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/README.md +587 -137
- 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 -26
- 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/serve.rip +0 -140
- package/ui.rip +0 -935
package/index.rip
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
|
|
13
|
+
notFound -> @send "#{dir}/index.html", 'text/html; charset=UTF-8'
|
|
14
|
+
|
|
15
|
+
start port: 3005
|
package/input.rip
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
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
|
+
onFocus: -> focused = true
|
|
21
|
+
onBlur: ->
|
|
22
|
+
focused = false
|
|
23
|
+
touched = true
|
|
24
|
+
|
|
25
|
+
render
|
|
26
|
+
input type: @type, value: @value, placeholder: @placeholder
|
|
27
|
+
disabled: @disabled
|
|
28
|
+
required: @required
|
|
29
|
+
aria-disabled: @disabled?!
|
|
30
|
+
aria-required: @required?!
|
|
31
|
+
$disabled: @disabled?!
|
|
32
|
+
$focused: focused?!
|
|
33
|
+
$touched: touched?!
|
|
34
|
+
@focusin: @onFocus
|
|
35
|
+
@focusout: @onBlur
|
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
|
package/multi-select.rip
ADDED
|
@@ -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)
|
package/nav-menu.rip
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# NavigationMenu — accessible headless site navigation
|
|
2
|
+
#
|
|
3
|
+
# Horizontal navigation with optional dropdown sub-menus. Triggers show
|
|
4
|
+
# content on hover or click. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# NavigationMenu
|
|
8
|
+
# a $link: true, href: "/", "Home"
|
|
9
|
+
# div $trigger: "products"
|
|
10
|
+
# div $panel: true
|
|
11
|
+
# a href: "/ui", "ui"
|
|
12
|
+
# a href: "/tools", "Tools"
|
|
13
|
+
# a $link: true, href: "/about", "About"
|
|
14
|
+
|
|
15
|
+
export NavigationMenu = component
|
|
16
|
+
@orientation := 'horizontal'
|
|
17
|
+
@hoverDelay := 200
|
|
18
|
+
@hoverCloseDelay := 300
|
|
19
|
+
|
|
20
|
+
activePanel := null
|
|
21
|
+
_ready := false
|
|
22
|
+
_hoverTimer := null
|
|
23
|
+
_closeTimer := null
|
|
24
|
+
|
|
25
|
+
_navItems ~=
|
|
26
|
+
return [] unless @_slot
|
|
27
|
+
Array.from(@_slot.children).filter (el) ->
|
|
28
|
+
el.dataset?.link? or el.dataset?.trigger?
|
|
29
|
+
|
|
30
|
+
mounted: -> _ready = true
|
|
31
|
+
|
|
32
|
+
beforeUnmount: ->
|
|
33
|
+
clearTimeout _hoverTimer if _hoverTimer
|
|
34
|
+
clearTimeout _closeTimer if _closeTimer
|
|
35
|
+
|
|
36
|
+
_openPanel: (id) ->
|
|
37
|
+
clearTimeout _closeTimer if _closeTimer
|
|
38
|
+
activePanel = id
|
|
39
|
+
requestAnimationFrame => @_position(id)
|
|
40
|
+
|
|
41
|
+
_closePanel: ->
|
|
42
|
+
activePanel = null
|
|
43
|
+
|
|
44
|
+
_scheduleOpen: (id) ->
|
|
45
|
+
clearTimeout _closeTimer if _closeTimer
|
|
46
|
+
_hoverTimer = setTimeout (=> @_openPanel(id)), @hoverDelay
|
|
47
|
+
|
|
48
|
+
_scheduleClose: ->
|
|
49
|
+
clearTimeout _hoverTimer if _hoverTimer
|
|
50
|
+
_closeTimer = setTimeout (=> @_closePanel()), @hoverCloseDelay
|
|
51
|
+
|
|
52
|
+
_cancelClose: ->
|
|
53
|
+
clearTimeout _closeTimer if _closeTimer
|
|
54
|
+
|
|
55
|
+
_position: (id) ->
|
|
56
|
+
trigger = @_root?.querySelector("[data-nav-trigger=\"#{id}\"]")
|
|
57
|
+
panel = @_root?.querySelector("[data-nav-panel=\"#{id}\"]")
|
|
58
|
+
return unless trigger and panel
|
|
59
|
+
tr = trigger.getBoundingClientRect()
|
|
60
|
+
panel.style.position = 'fixed'
|
|
61
|
+
panel.style.left = "#{tr.left}px"
|
|
62
|
+
panel.style.top = "#{tr.bottom + 2}px"
|
|
63
|
+
|
|
64
|
+
_onKeydown: (e) ->
|
|
65
|
+
navBtns = @_root?.querySelectorAll('[data-nav-trigger], [data-nav-link]')
|
|
66
|
+
return unless navBtns?.length
|
|
67
|
+
focused = Array.from(navBtns).indexOf(document.activeElement)
|
|
68
|
+
return if focused < 0
|
|
69
|
+
len = navBtns.length
|
|
70
|
+
switch e.key
|
|
71
|
+
when 'ArrowRight'
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
navBtns[(focused + 1) %% len]?.focus()
|
|
74
|
+
when 'ArrowLeft'
|
|
75
|
+
e.preventDefault()
|
|
76
|
+
navBtns[(focused - 1) %% len]?.focus()
|
|
77
|
+
when 'ArrowDown'
|
|
78
|
+
triggerId = document.activeElement?.dataset?.navTrigger
|
|
79
|
+
if triggerId
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
@_openPanel(triggerId)
|
|
82
|
+
@_root?.querySelector("[data-nav-panel=\"#{triggerId}\"] a, [data-nav-panel=\"#{triggerId}\"] button")?.focus()
|
|
83
|
+
when 'Escape'
|
|
84
|
+
@_closePanel()
|
|
85
|
+
|
|
86
|
+
~>
|
|
87
|
+
return unless _ready
|
|
88
|
+
if activePanel
|
|
89
|
+
onDown = (e) =>
|
|
90
|
+
unless @_root?.contains(e.target)
|
|
91
|
+
@_closePanel()
|
|
92
|
+
document.addEventListener 'mousedown', onDown
|
|
93
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
94
|
+
|
|
95
|
+
render
|
|
96
|
+
nav ref: "_root", role: "navigation", aria-orientation: @orientation
|
|
97
|
+
$orientation: @orientation
|
|
98
|
+
|
|
99
|
+
. ref: "_slot", style: "display:none"
|
|
100
|
+
slot
|
|
101
|
+
|
|
102
|
+
for navItem, nIdx in _navItems
|
|
103
|
+
if navItem.dataset.link?
|
|
104
|
+
a href: navItem.getAttribute('href') or '#'
|
|
105
|
+
"data-nav-link": true
|
|
106
|
+
tabindex: "0"
|
|
107
|
+
@keydown: @_onKeydown
|
|
108
|
+
navItem.textContent
|
|
109
|
+
else if navItem.dataset.trigger?
|
|
110
|
+
. style: "display:inline-block;position:relative"
|
|
111
|
+
button "data-nav-trigger": navItem.dataset.trigger
|
|
112
|
+
tabindex: "0"
|
|
113
|
+
aria-expanded: activePanel is navItem.dataset.trigger
|
|
114
|
+
$open: (activePanel is navItem.dataset.trigger)?!
|
|
115
|
+
@click: (=> if activePanel is navItem.dataset.trigger then @_closePanel() else @_openPanel(navItem.dataset.trigger))
|
|
116
|
+
@mouseenter: (=> @_scheduleOpen(navItem.dataset.trigger))
|
|
117
|
+
@mouseleave: (=> @_scheduleClose())
|
|
118
|
+
@keydown: @_onKeydown
|
|
119
|
+
navItem.dataset.trigger
|
|
120
|
+
|
|
121
|
+
if activePanel is navItem.dataset.trigger
|
|
122
|
+
div "data-nav-panel": navItem.dataset.trigger, $open: true
|
|
123
|
+
style: "position:fixed;z-index:50"
|
|
124
|
+
@mouseenter: (=> @_cancelClose())
|
|
125
|
+
@mouseleave: (=> @_scheduleClose())
|
|
126
|
+
for link, lIdx in Array.from(navItem.querySelectorAll('a, [data-link]'))
|
|
127
|
+
a href: link.getAttribute('href') or '#', tabindex: "0"
|
|
128
|
+
@keydown: (e) =>
|
|
129
|
+
if e.key is 'Escape'
|
|
130
|
+
@_closePanel()
|
|
131
|
+
@_root?.querySelector("[data-nav-trigger=\"#{navItem.dataset.trigger}\"]")?.focus()
|
|
132
|
+
link.textContent
|