@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.
- package/README.md +443 -576
- package/accordion.rip +113 -0
- package/alert-dialog.rip +96 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/badge.rip +15 -0
- package/breadcrumb.rip +46 -0
- package/button-group.rip +26 -0
- package/button.rip +23 -0
- package/card.rip +25 -0
- package/carousel.rip +110 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/collapsible.rip +50 -0
- package/combobox.rip +155 -0
- package/context-menu.rip +105 -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 +16 -0
- package/input-group.rip +28 -0
- package/input.rip +36 -0
- package/label.rip +16 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/native-select.rip +32 -0
- package/nav-menu.rip +129 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +18 -27
- package/pagination.rip +123 -0
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/resizable.rip +123 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/skeleton.rip +22 -0
- package/slider.rip +165 -0
- package/spinner.rip +17 -0
- package/table.rip +27 -0
- package/tabs.rip +124 -0
- package/textarea.rip +48 -0
- package/toast.rip +87 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/dist/rip-ui.min.js +0 -524
- package/dist/rip-ui.min.js.br +0 -0
- package/serve.rip +0 -92
- package/ui.rip +0 -964
package/accordion.rip
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Accordion — accessible headless expand/collapse widget
|
|
2
|
+
#
|
|
3
|
+
# Supports single or multiple expanded sections. Keyboard: Enter/Space to
|
|
4
|
+
# toggle, ArrowDown/Up to move between triggers. Exposes $open on items.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Accordion multiple: false
|
|
9
|
+
# div $item: "a"
|
|
10
|
+
# button $trigger: true, "Section A"
|
|
11
|
+
# div $content: true
|
|
12
|
+
# p "Content A"
|
|
13
|
+
# div $item: "b"
|
|
14
|
+
# button $trigger: true, "Section B"
|
|
15
|
+
# div $content: true
|
|
16
|
+
# p "Content B"
|
|
17
|
+
|
|
18
|
+
export Accordion = component
|
|
19
|
+
@multiple := false
|
|
20
|
+
|
|
21
|
+
openItems := new Set()
|
|
22
|
+
_ready := false
|
|
23
|
+
_id =! "acc-#{Math.random().toString(36).slice(2, 8)}"
|
|
24
|
+
|
|
25
|
+
mounted: ->
|
|
26
|
+
_ready = true
|
|
27
|
+
@_content?.querySelectorAll('[data-trigger]').forEach (trigger) =>
|
|
28
|
+
item = trigger.closest('[data-item]')
|
|
29
|
+
return unless item
|
|
30
|
+
id = item.dataset.item
|
|
31
|
+
trigger.addEventListener 'click', =>
|
|
32
|
+
return if item.hasAttribute('data-disabled')
|
|
33
|
+
@toggle(id)
|
|
34
|
+
trigger.addEventListener 'keydown', (e) => @onTriggerKeydown(e, id)
|
|
35
|
+
|
|
36
|
+
~>
|
|
37
|
+
return unless _ready
|
|
38
|
+
@_content?.querySelectorAll('[data-item]').forEach (item) =>
|
|
39
|
+
id = item.dataset.item
|
|
40
|
+
isOpen = openItems.has(id)
|
|
41
|
+
item.toggleAttribute 'data-open', isOpen
|
|
42
|
+
trigger = item.querySelector('[data-trigger]')
|
|
43
|
+
content = item.querySelector('[data-content]')
|
|
44
|
+
triggerId = "#{_id}-trigger-#{id}"
|
|
45
|
+
panelId = "#{_id}-panel-#{id}"
|
|
46
|
+
if trigger
|
|
47
|
+
isDisabled = item.hasAttribute('data-disabled')
|
|
48
|
+
trigger.id = triggerId
|
|
49
|
+
trigger.setAttribute 'aria-expanded', isOpen
|
|
50
|
+
trigger.setAttribute 'aria-controls', panelId
|
|
51
|
+
trigger.setAttribute 'aria-disabled', isDisabled if isDisabled
|
|
52
|
+
trigger.tabIndex = if isDisabled then -1 else 0
|
|
53
|
+
if content
|
|
54
|
+
content.id = panelId
|
|
55
|
+
content.hidden = if isOpen then false else 'until-found'
|
|
56
|
+
content.setAttribute 'role', 'region'
|
|
57
|
+
content.setAttribute 'aria-labelledby', triggerId
|
|
58
|
+
if isOpen
|
|
59
|
+
rect = content.getBoundingClientRect()
|
|
60
|
+
content.style.setProperty '--accordion-panel-height', "#{rect.height}px"
|
|
61
|
+
content.style.setProperty '--accordion-panel-width', "#{rect.width}px"
|
|
62
|
+
|
|
63
|
+
toggle: (id) ->
|
|
64
|
+
if openItems.has(id)
|
|
65
|
+
openItems.delete(id)
|
|
66
|
+
else
|
|
67
|
+
openItems.clear() unless @multiple
|
|
68
|
+
openItems.add(id)
|
|
69
|
+
openItems = new Set(openItems)
|
|
70
|
+
@emit 'change', Array.from(openItems)
|
|
71
|
+
|
|
72
|
+
isOpen: (id) ->
|
|
73
|
+
openItems.has(id)
|
|
74
|
+
|
|
75
|
+
onTriggerKeydown: (e, id) ->
|
|
76
|
+
item = e.currentTarget.closest('[data-item]')
|
|
77
|
+
return if item?.hasAttribute('data-disabled') and e.key in ['Enter', ' ']
|
|
78
|
+
switch e.key
|
|
79
|
+
when 'Enter', ' '
|
|
80
|
+
e.preventDefault()
|
|
81
|
+
@toggle(id)
|
|
82
|
+
when 'ArrowDown'
|
|
83
|
+
e.preventDefault()
|
|
84
|
+
@_focusNext(1)
|
|
85
|
+
when 'ArrowUp'
|
|
86
|
+
e.preventDefault()
|
|
87
|
+
@_focusNext(-1)
|
|
88
|
+
when 'Home'
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
@_focusTrigger(0)
|
|
91
|
+
when 'End'
|
|
92
|
+
e.preventDefault()
|
|
93
|
+
@_focusTrigger(-1)
|
|
94
|
+
|
|
95
|
+
_triggers: ->
|
|
96
|
+
return [] unless @_content
|
|
97
|
+
Array.from(@_content.querySelectorAll('[data-trigger]'))
|
|
98
|
+
|
|
99
|
+
_focusNext: (dir) ->
|
|
100
|
+
triggers = @_triggers()
|
|
101
|
+
idx = triggers.indexOf(document.activeElement)
|
|
102
|
+
return if idx is -1
|
|
103
|
+
next = (idx + dir) %% triggers.length
|
|
104
|
+
triggers[next]?.focus()
|
|
105
|
+
|
|
106
|
+
_focusTrigger: (idx) ->
|
|
107
|
+
triggers = @_triggers()
|
|
108
|
+
target = if idx < 0 then triggers[triggers.length - 1] else triggers[idx]
|
|
109
|
+
target?.focus()
|
|
110
|
+
|
|
111
|
+
render
|
|
112
|
+
div ref: "_content"
|
|
113
|
+
slot
|
package/alert-dialog.rip
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# AlertDialog — accessible headless non-dismissable modal
|
|
2
|
+
#
|
|
3
|
+
# A Dialog variant that requires explicit user action to close.
|
|
4
|
+
# Cannot be dismissed by clicking outside or pressing Escape.
|
|
5
|
+
# Use for destructive confirmations, unsaved changes, etc.
|
|
6
|
+
# Ships zero CSS.
|
|
7
|
+
#
|
|
8
|
+
# Usage:
|
|
9
|
+
# AlertDialog open <=> showConfirm
|
|
10
|
+
# h2 "Delete account?"
|
|
11
|
+
# p "This action cannot be undone."
|
|
12
|
+
# button @click: (=> showConfirm = false), "Cancel"
|
|
13
|
+
# button @click: handleDelete, "Delete"
|
|
14
|
+
|
|
15
|
+
alertDialogStack = []
|
|
16
|
+
|
|
17
|
+
export AlertDialog = component
|
|
18
|
+
@open := false
|
|
19
|
+
@initialFocus := null
|
|
20
|
+
|
|
21
|
+
_prevFocus = null
|
|
22
|
+
_cleanupTrap = null
|
|
23
|
+
_scrollY = 0
|
|
24
|
+
_id =! "adlg-#{Math.random().toString(36).slice(2, 8)}"
|
|
25
|
+
|
|
26
|
+
_wireAria: ->
|
|
27
|
+
panel = @_panel
|
|
28
|
+
return unless panel
|
|
29
|
+
heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
|
|
30
|
+
if heading
|
|
31
|
+
heading.id ?= "#{_id}-title"
|
|
32
|
+
panel.setAttribute 'aria-labelledby', heading.id
|
|
33
|
+
desc = panel.querySelector('p')
|
|
34
|
+
if desc
|
|
35
|
+
desc.id ?= "#{_id}-desc"
|
|
36
|
+
panel.setAttribute 'aria-describedby', desc.id
|
|
37
|
+
|
|
38
|
+
~>
|
|
39
|
+
if @open
|
|
40
|
+
_prevFocus = document.activeElement
|
|
41
|
+
_scrollY = window.scrollY
|
|
42
|
+
alertDialogStack.push this
|
|
43
|
+
document.body.style.position = 'fixed'
|
|
44
|
+
document.body.style.top = "-#{_scrollY}px"
|
|
45
|
+
document.body.style.width = '100%'
|
|
46
|
+
|
|
47
|
+
setTimeout =>
|
|
48
|
+
panel = @_panel
|
|
49
|
+
if panel
|
|
50
|
+
@_wireAria()
|
|
51
|
+
if @initialFocus
|
|
52
|
+
target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
|
|
53
|
+
target?.focus()
|
|
54
|
+
else
|
|
55
|
+
focusable = panel.querySelectorAll 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
|
|
56
|
+
focusable[0]?.focus()
|
|
57
|
+
_cleanupTrap = (e) ->
|
|
58
|
+
return unless e.key is 'Tab'
|
|
59
|
+
list = Array.from(panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter (f) -> f.offsetParent isnt null
|
|
60
|
+
return unless list.length
|
|
61
|
+
first = list[0]
|
|
62
|
+
last = list[list.length - 1]
|
|
63
|
+
if e.shiftKey
|
|
64
|
+
if document.activeElement is first then (e.preventDefault(); last.focus())
|
|
65
|
+
else
|
|
66
|
+
if document.activeElement is last then (e.preventDefault(); first.focus())
|
|
67
|
+
panel.addEventListener 'keydown', _cleanupTrap
|
|
68
|
+
, 0
|
|
69
|
+
|
|
70
|
+
return ->
|
|
71
|
+
idx = alertDialogStack.indexOf this
|
|
72
|
+
alertDialogStack.splice(idx, 1) if idx >= 0
|
|
73
|
+
document.body.style.position = '' unless alertDialogStack.length
|
|
74
|
+
document.body.style.top = '' unless alertDialogStack.length
|
|
75
|
+
document.body.style.width = '' unless alertDialogStack.length
|
|
76
|
+
window.scrollTo 0, _scrollY unless alertDialogStack.length
|
|
77
|
+
_prevFocus?.focus()
|
|
78
|
+
else
|
|
79
|
+
idx = alertDialogStack.indexOf this
|
|
80
|
+
alertDialogStack.splice(idx, 1) if idx >= 0
|
|
81
|
+
unless alertDialogStack.length
|
|
82
|
+
document.body.style.position = ''
|
|
83
|
+
document.body.style.top = ''
|
|
84
|
+
document.body.style.width = ''
|
|
85
|
+
window.scrollTo 0, _scrollY
|
|
86
|
+
_prevFocus?.focus()
|
|
87
|
+
|
|
88
|
+
close: ->
|
|
89
|
+
@open = false
|
|
90
|
+
@emit 'close'
|
|
91
|
+
|
|
92
|
+
render
|
|
93
|
+
if @open
|
|
94
|
+
div ref: "_backdrop", $open: true
|
|
95
|
+
div ref: "_panel", role: "alertdialog", aria-modal: "true", tabindex: "-1"
|
|
96
|
+
slot
|
package/autocomplete.rip
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Autocomplete — accessible headless suggestion input
|
|
2
|
+
#
|
|
3
|
+
# Like Combobox but the input value IS the value (no selection from a list).
|
|
4
|
+
# Suggestions are shown as the user types; selecting a suggestion fills the input.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Autocomplete value <=> city, items: cities, @filter: filterCities
|
|
9
|
+
|
|
10
|
+
acCollator = new Intl.Collator(undefined, { sensitivity: 'base' })
|
|
11
|
+
|
|
12
|
+
export Autocomplete = component
|
|
13
|
+
@value := ''
|
|
14
|
+
@items := []
|
|
15
|
+
@placeholder := 'Type to search...'
|
|
16
|
+
@disabled := false
|
|
17
|
+
|
|
18
|
+
open := false
|
|
19
|
+
|
|
20
|
+
filteredItems ~=
|
|
21
|
+
q = @value.trim()
|
|
22
|
+
return @items unless q
|
|
23
|
+
@items.filter (item) ->
|
|
24
|
+
label = if typeof item is 'string' then item else (item.label or item.name or String(item))
|
|
25
|
+
acCollator.compare(label.slice(0, q.length), q) is 0
|
|
26
|
+
|
|
27
|
+
_listId =! "ac-list-#{Math.random().toString(36).slice(2, 8)}"
|
|
28
|
+
|
|
29
|
+
_getItems: ->
|
|
30
|
+
return [] unless @_list
|
|
31
|
+
Array.from(@_list.querySelectorAll('[role="option"]'))
|
|
32
|
+
|
|
33
|
+
_updateHighlight: ->
|
|
34
|
+
idx = @_hlIdx
|
|
35
|
+
opts = @_getItems()
|
|
36
|
+
opts.forEach (el, ndx) ->
|
|
37
|
+
el.id = "#{@_listId}-opt-#{ndx}" unless el.id
|
|
38
|
+
el.toggleAttribute 'data-highlighted', ndx is idx
|
|
39
|
+
activeId = if idx >= 0 and opts[idx] then opts[idx].id else undefined
|
|
40
|
+
if @_input
|
|
41
|
+
if activeId then @_input.setAttribute 'aria-activedescendant', activeId
|
|
42
|
+
else @_input.removeAttribute 'aria-activedescendant'
|
|
43
|
+
opts[idx]?.scrollIntoView({ block: 'nearest' })
|
|
44
|
+
|
|
45
|
+
openMenu: ->
|
|
46
|
+
open = true
|
|
47
|
+
@_hlIdx = -1
|
|
48
|
+
setTimeout => @_position(), 0
|
|
49
|
+
|
|
50
|
+
close: ->
|
|
51
|
+
open = false
|
|
52
|
+
@_hlIdx = -1
|
|
53
|
+
|
|
54
|
+
_position: ->
|
|
55
|
+
return unless @_input and @_list
|
|
56
|
+
tr = @_input.getBoundingClientRect()
|
|
57
|
+
@_list.style.position = 'fixed'
|
|
58
|
+
@_list.style.left = "#{tr.left}px"
|
|
59
|
+
@_list.style.top = "#{tr.bottom + 2}px"
|
|
60
|
+
@_list.style.minWidth = "#{tr.width}px"
|
|
61
|
+
fl = @_list.getBoundingClientRect()
|
|
62
|
+
if fl.bottom > window.innerHeight
|
|
63
|
+
@_list.style.top = "#{tr.top - fl.height - 2}px"
|
|
64
|
+
|
|
65
|
+
selectIndex: (idx) ->
|
|
66
|
+
item = filteredItems[idx]
|
|
67
|
+
return unless item
|
|
68
|
+
label = if typeof item is 'string' then item else (item.label or item.name or String(item))
|
|
69
|
+
@value = label
|
|
70
|
+
@_input?.value = label
|
|
71
|
+
@emit 'select', item
|
|
72
|
+
@close()
|
|
73
|
+
|
|
74
|
+
onInput: (e) ->
|
|
75
|
+
newVal = e.target.value
|
|
76
|
+
return if newVal is @value
|
|
77
|
+
@value = newVal
|
|
78
|
+
open = true
|
|
79
|
+
@_hlIdx = if filteredItems.length > 0 then 0 else -1
|
|
80
|
+
setTimeout =>
|
|
81
|
+
@_position()
|
|
82
|
+
@_updateHighlight()
|
|
83
|
+
, 0
|
|
84
|
+
|
|
85
|
+
onKeydown: (e) ->
|
|
86
|
+
len = filteredItems.length
|
|
87
|
+
switch e.key
|
|
88
|
+
when 'ArrowDown'
|
|
89
|
+
e.preventDefault()
|
|
90
|
+
@openMenu() unless open
|
|
91
|
+
if len
|
|
92
|
+
@_hlIdx = (@_hlIdx + 1) %% len
|
|
93
|
+
@_updateHighlight()
|
|
94
|
+
when 'ArrowUp'
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
@openMenu() unless open
|
|
97
|
+
if len
|
|
98
|
+
@_hlIdx = if @_hlIdx <= 0 then len - 1 else @_hlIdx - 1
|
|
99
|
+
@_updateHighlight()
|
|
100
|
+
when 'Enter'
|
|
101
|
+
e.preventDefault()
|
|
102
|
+
@selectIndex(@_hlIdx) if @_hlIdx >= 0
|
|
103
|
+
when 'Escape'
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
@close()
|
|
106
|
+
when 'Tab'
|
|
107
|
+
@close()
|
|
108
|
+
|
|
109
|
+
~>
|
|
110
|
+
if open
|
|
111
|
+
onDown = (e) =>
|
|
112
|
+
unless @_input?.contains(e.target) or @_list?.contains(e.target)
|
|
113
|
+
@close()
|
|
114
|
+
document.addEventListener 'mousedown', onDown
|
|
115
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
116
|
+
|
|
117
|
+
mounted: ->
|
|
118
|
+
@_hlIdx = -1
|
|
119
|
+
@_input.value = @value if @_input and @value
|
|
120
|
+
|
|
121
|
+
render
|
|
122
|
+
. $open: open?!
|
|
123
|
+
|
|
124
|
+
input ref: "_input", role: "combobox", type: "text"
|
|
125
|
+
autocomplete: "off"
|
|
126
|
+
aria-expanded: !!open
|
|
127
|
+
aria-haspopup: "listbox"
|
|
128
|
+
aria-autocomplete: "list"
|
|
129
|
+
aria-controls: open ? _listId : undefined
|
|
130
|
+
$disabled: @disabled?!
|
|
131
|
+
disabled: @disabled
|
|
132
|
+
placeholder: @placeholder
|
|
133
|
+
@input: @onInput
|
|
134
|
+
|
|
135
|
+
if open and filteredItems.length > 0
|
|
136
|
+
div ref: "_list", id: _listId, role: "listbox", $open: true, style: "position:fixed"
|
|
137
|
+
for item, idx in filteredItems
|
|
138
|
+
div role: "option", tabindex: "-1"
|
|
139
|
+
@click: (=> @selectIndex(idx))
|
|
140
|
+
@mouseenter: (=> @_hlIdx = idx; @_updateHighlight())
|
|
141
|
+
"#{if typeof item is 'string' then item else (item.label or item.name or String(item))}"
|
package/avatar.rip
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Avatar — accessible headless avatar
|
|
2
|
+
#
|
|
3
|
+
# Shows an image, falls back to initials or a generic icon placeholder.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Avatar src: user.photoUrl, alt: user.name, fallback: "AC"
|
|
8
|
+
# Avatar fallback: "JD"
|
|
9
|
+
# Avatar
|
|
10
|
+
|
|
11
|
+
export Avatar = component
|
|
12
|
+
@src := ''
|
|
13
|
+
@alt := ''
|
|
14
|
+
@fallback := ''
|
|
15
|
+
|
|
16
|
+
imgError := false
|
|
17
|
+
|
|
18
|
+
_onError: -> imgError = true
|
|
19
|
+
|
|
20
|
+
_initials ~=
|
|
21
|
+
return @fallback if @fallback
|
|
22
|
+
return '' unless @alt
|
|
23
|
+
parts = @alt.trim().split(/\s+/)
|
|
24
|
+
chars = parts.map (p) -> p[0]?.toUpperCase() or ''
|
|
25
|
+
chars.slice(0, 2).join('')
|
|
26
|
+
|
|
27
|
+
render
|
|
28
|
+
span role: "img", aria-label: @alt or 'Avatar'
|
|
29
|
+
$status: if @src and not imgError then 'image' else if _initials then 'fallback' else 'placeholder'
|
|
30
|
+
if @src and not imgError
|
|
31
|
+
img src: @src, alt: @alt, @error: @_onError
|
|
32
|
+
else if _initials
|
|
33
|
+
span $initials: true
|
|
34
|
+
_initials
|
|
35
|
+
else
|
|
36
|
+
span $placeholder: true
|
|
37
|
+
"?"
|
package/badge.rip
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Badge — accessible headless inline label
|
|
2
|
+
#
|
|
3
|
+
# Decorative label for status, counts, or categories.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Badge "New"
|
|
8
|
+
# Badge variant: "outline", "Beta"
|
|
9
|
+
|
|
10
|
+
export Badge = component
|
|
11
|
+
@variant := 'solid'
|
|
12
|
+
|
|
13
|
+
render
|
|
14
|
+
span $variant: @variant
|
|
15
|
+
slot
|
package/breadcrumb.rip
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# Breadcrumb — accessible headless navigation breadcrumb
|
|
2
|
+
#
|
|
3
|
+
# Renders a navigation trail with separator between items.
|
|
4
|
+
# The last item is automatically marked as the current page.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Breadcrumb
|
|
9
|
+
# a $item: true, href: "/", "Home"
|
|
10
|
+
# a $item: true, href: "/products", "Products"
|
|
11
|
+
# span $item: true, "Widget Pro"
|
|
12
|
+
#
|
|
13
|
+
# Breadcrumb separator: ">"
|
|
14
|
+
# a $item: true, href: "/", "Home"
|
|
15
|
+
# span $item: true, "Settings"
|
|
16
|
+
|
|
17
|
+
export Breadcrumb = component
|
|
18
|
+
@separator := '/'
|
|
19
|
+
@label := 'Breadcrumb'
|
|
20
|
+
|
|
21
|
+
_ready := false
|
|
22
|
+
|
|
23
|
+
mounted: -> _ready = true
|
|
24
|
+
|
|
25
|
+
_items ~=
|
|
26
|
+
return [] unless _ready
|
|
27
|
+
return [] unless @_content
|
|
28
|
+
Array.from(@_content.querySelectorAll('[data-item]') or [])
|
|
29
|
+
|
|
30
|
+
~>
|
|
31
|
+
return unless _ready
|
|
32
|
+
items = _items
|
|
33
|
+
return unless items.length
|
|
34
|
+
items.forEach (el, idx) =>
|
|
35
|
+
isLast = idx is items.length - 1
|
|
36
|
+
if isLast
|
|
37
|
+
el.setAttribute 'aria-current', 'page'
|
|
38
|
+
el.toggleAttribute 'data-current', true
|
|
39
|
+
else
|
|
40
|
+
el.removeAttribute 'aria-current'
|
|
41
|
+
el.removeAttribute 'data-current'
|
|
42
|
+
|
|
43
|
+
render
|
|
44
|
+
nav aria-label: @label
|
|
45
|
+
ol ref: "_content"
|
|
46
|
+
slot
|
package/button-group.rip
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# ButtonGroup — accessible headless button group
|
|
2
|
+
#
|
|
3
|
+
# Groups related buttons with proper ARIA semantics.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# ButtonGroup
|
|
8
|
+
# Button "Cut"
|
|
9
|
+
# Button "Copy"
|
|
10
|
+
# Button "Paste"
|
|
11
|
+
# ButtonGroup orientation: "vertical", label: "Text formatting"
|
|
12
|
+
# Toggle pressed <=> isBold, "Bold"
|
|
13
|
+
# Toggle pressed <=> isItalic, "Italic"
|
|
14
|
+
|
|
15
|
+
export ButtonGroup = component
|
|
16
|
+
@orientation := 'horizontal'
|
|
17
|
+
@disabled := false
|
|
18
|
+
@label := ''
|
|
19
|
+
|
|
20
|
+
render
|
|
21
|
+
div role: "group"
|
|
22
|
+
aria-label: @label?!
|
|
23
|
+
aria-orientation: @orientation
|
|
24
|
+
$orientation: @orientation
|
|
25
|
+
$disabled: @disabled?!
|
|
26
|
+
slot
|
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
|
package/card.rip
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Card — accessible headless content container
|
|
2
|
+
#
|
|
3
|
+
# Structured container with optional header, content, and footer sections.
|
|
4
|
+
# Use $header, $content, $footer on children to mark sections.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Card
|
|
9
|
+
# div $header: true
|
|
10
|
+
# h3 "Title"
|
|
11
|
+
# div $content: true
|
|
12
|
+
# p "Body text"
|
|
13
|
+
# div $footer: true
|
|
14
|
+
# Button "Action"
|
|
15
|
+
#
|
|
16
|
+
# Card interactive: true, @click: handleClick
|
|
17
|
+
# p "Clickable card"
|
|
18
|
+
|
|
19
|
+
export Card = component
|
|
20
|
+
@interactive := false
|
|
21
|
+
|
|
22
|
+
render
|
|
23
|
+
div tabindex: (if @interactive then "0" else undefined)
|
|
24
|
+
$interactive: @interactive?!
|
|
25
|
+
slot
|
package/carousel.rip
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# Carousel — accessible headless slide carousel
|
|
2
|
+
#
|
|
3
|
+
# Displays one slide at a time with arrow key navigation, optional
|
|
4
|
+
# autoplay, and loop mode. Discovers slides from [data-slide] children.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Carousel loop: true
|
|
9
|
+
# div $slide: true
|
|
10
|
+
# img src: "slide1.jpg"
|
|
11
|
+
# div $slide: true
|
|
12
|
+
# img src: "slide2.jpg"
|
|
13
|
+
# div $slide: true
|
|
14
|
+
# img src: "slide3.jpg"
|
|
15
|
+
#
|
|
16
|
+
# Carousel autoplay: true, interval: 5000, @change: handleSlide
|
|
17
|
+
# div $slide: true, "Slide A"
|
|
18
|
+
# div $slide: true, "Slide B"
|
|
19
|
+
|
|
20
|
+
export Carousel = component
|
|
21
|
+
@orientation := 'horizontal'
|
|
22
|
+
@loop := false
|
|
23
|
+
@autoplay := false
|
|
24
|
+
@interval := 4000
|
|
25
|
+
@label := 'Carousel'
|
|
26
|
+
|
|
27
|
+
activeIndex := 0
|
|
28
|
+
_ready := false
|
|
29
|
+
_timer = null
|
|
30
|
+
|
|
31
|
+
_slides ~=
|
|
32
|
+
return [] unless _ready
|
|
33
|
+
return [] unless @_content
|
|
34
|
+
Array.from(@_content.querySelectorAll('[data-slide]') or [])
|
|
35
|
+
|
|
36
|
+
totalSlides ~= _slides.length
|
|
37
|
+
|
|
38
|
+
mounted: ->
|
|
39
|
+
_ready = true
|
|
40
|
+
@_startAutoplay() if @autoplay
|
|
41
|
+
|
|
42
|
+
beforeUnmount: ->
|
|
43
|
+
@_stopAutoplay()
|
|
44
|
+
|
|
45
|
+
_startAutoplay: ->
|
|
46
|
+
@_stopAutoplay()
|
|
47
|
+
_timer = setInterval (=> @next()), @interval
|
|
48
|
+
|
|
49
|
+
_stopAutoplay: ->
|
|
50
|
+
clearInterval _timer if _timer
|
|
51
|
+
_timer = null
|
|
52
|
+
|
|
53
|
+
goto: (idx) ->
|
|
54
|
+
count = totalSlides
|
|
55
|
+
return unless count
|
|
56
|
+
if @loop
|
|
57
|
+
idx = idx %% count
|
|
58
|
+
else
|
|
59
|
+
idx = Math.max(0, Math.min(idx, count - 1))
|
|
60
|
+
activeIndex = idx
|
|
61
|
+
@emit 'change', activeIndex
|
|
62
|
+
|
|
63
|
+
next: -> @goto(activeIndex + 1)
|
|
64
|
+
prev: -> @goto(activeIndex - 1)
|
|
65
|
+
|
|
66
|
+
onKeydown: (e) ->
|
|
67
|
+
horiz = @orientation is 'horizontal'
|
|
68
|
+
switch e.key
|
|
69
|
+
when (if horiz then 'ArrowRight' else 'ArrowDown')
|
|
70
|
+
e.preventDefault()
|
|
71
|
+
@next()
|
|
72
|
+
when (if horiz then 'ArrowLeft' else 'ArrowUp')
|
|
73
|
+
e.preventDefault()
|
|
74
|
+
@prev()
|
|
75
|
+
when 'Home'
|
|
76
|
+
e.preventDefault()
|
|
77
|
+
@goto(0)
|
|
78
|
+
when 'End'
|
|
79
|
+
e.preventDefault()
|
|
80
|
+
@goto(totalSlides - 1)
|
|
81
|
+
|
|
82
|
+
~>
|
|
83
|
+
return unless _ready
|
|
84
|
+
_slides.forEach (el, idx) =>
|
|
85
|
+
isActive = idx is activeIndex
|
|
86
|
+
el.hidden = not isActive
|
|
87
|
+
el.toggleAttribute 'data-active', isActive
|
|
88
|
+
el.setAttribute 'role', 'tabpanel'
|
|
89
|
+
el.setAttribute 'aria-roledescription', 'slide'
|
|
90
|
+
el.setAttribute 'aria-label', "Slide #{idx + 1} of #{totalSlides}"
|
|
91
|
+
|
|
92
|
+
onMouseenter: -> @_stopAutoplay() if @autoplay
|
|
93
|
+
onMouseleave: -> @_startAutoplay() if @autoplay
|
|
94
|
+
|
|
95
|
+
render
|
|
96
|
+
div role: "region", aria-roledescription: "carousel", aria-label: @label, tabindex: "0"
|
|
97
|
+
$orientation: @orientation
|
|
98
|
+
@keydown: @onKeydown
|
|
99
|
+
@mouseenter: @onMouseenter
|
|
100
|
+
@mouseleave: @onMouseleave
|
|
101
|
+
button $prev: true, aria-label: "Previous slide"
|
|
102
|
+
disabled: not @loop and activeIndex <= 0
|
|
103
|
+
$disabled: (not @loop and activeIndex <= 0)?!
|
|
104
|
+
@click: (=> @prev())
|
|
105
|
+
div ref: "_content"
|
|
106
|
+
slot
|
|
107
|
+
button $next: true, aria-label: "Next slide"
|
|
108
|
+
disabled: not @loop and activeIndex >= totalSlides - 1
|
|
109
|
+
$disabled: (not @loop and activeIndex >= totalSlides - 1)?!
|
|
110
|
+
@click: (=> @next())
|