rip-lang 3.13.119 → 3.13.121
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 +11 -2
- package/docs/RIP-LANG.md +4 -0
- package/docs/dist/rip.js +257 -27
- package/docs/dist/rip.min.js +183 -183
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/ui/accordion.rip +103 -0
- package/docs/ui/alert-dialog.rip +53 -0
- package/docs/ui/autocomplete.rip +115 -0
- package/docs/ui/avatar.rip +37 -0
- package/docs/ui/badge.rip +15 -0
- package/docs/ui/breadcrumb.rip +47 -0
- package/docs/ui/button-group.rip +26 -0
- package/docs/ui/button.rip +23 -0
- package/docs/ui/card.rip +25 -0
- package/docs/ui/carousel.rip +110 -0
- package/docs/ui/checkbox-group.rip +61 -0
- package/docs/ui/checkbox.rip +33 -0
- package/docs/ui/collapsible.rip +50 -0
- package/docs/ui/combobox.rip +130 -0
- package/docs/ui/context-menu.rip +88 -0
- package/docs/ui/date-picker.rip +206 -0
- package/docs/ui/dialog.rip +60 -0
- package/docs/ui/drawer.rip +58 -0
- package/docs/ui/editable-value.rip +82 -0
- package/docs/ui/field.rip +53 -0
- package/docs/ui/fieldset.rip +22 -0
- package/docs/ui/form.rip +39 -0
- package/docs/ui/grid.rip +901 -0
- package/docs/ui/hljs-rip.js +209 -0
- package/docs/ui/index.css +1797 -0
- package/docs/ui/index.html +2385 -0
- package/docs/ui/input-group.rip +28 -0
- package/docs/ui/input.rip +36 -0
- package/docs/ui/label.rip +16 -0
- package/docs/ui/menu.rip +134 -0
- package/docs/ui/menubar.rip +151 -0
- package/docs/ui/meter.rip +36 -0
- package/docs/ui/multi-select.rip +203 -0
- package/docs/ui/native-select.rip +33 -0
- package/docs/ui/nav-menu.rip +126 -0
- package/docs/ui/number-field.rip +162 -0
- package/docs/ui/otp-field.rip +89 -0
- package/docs/ui/pagination.rip +123 -0
- package/docs/ui/popover.rip +93 -0
- package/docs/ui/preview-card.rip +75 -0
- package/docs/ui/progress.rip +25 -0
- package/docs/ui/radio-group.rip +57 -0
- package/docs/ui/resizable.rip +123 -0
- package/docs/ui/scroll-area.rip +145 -0
- package/docs/ui/select.rip +151 -0
- package/docs/ui/separator.rip +17 -0
- package/docs/ui/skeleton.rip +22 -0
- package/docs/ui/slider.rip +165 -0
- package/docs/ui/spinner.rip +17 -0
- package/docs/ui/table.rip +27 -0
- package/docs/ui/tabs.rip +113 -0
- package/docs/ui/textarea.rip +48 -0
- package/docs/ui/toast.rip +87 -0
- package/docs/ui/toggle-group.rip +71 -0
- package/docs/ui/toggle.rip +24 -0
- package/docs/ui/toolbar.rip +38 -0
- package/docs/ui/tooltip.rip +85 -0
- package/package.json +1 -1
- package/src/compiler.js +24 -12
- package/src/components.js +43 -6
- package/src/grammar/grammar.rip +2 -2
- package/src/lexer.js +26 -0
- package/src/parser.js +2 -2
- package/src/sourcemap-utils.js +91 -0
- package/src/typecheck.js +33 -8
- package/src/ui.rip +118 -2
package/docs/dist/rip.min.js.br
CHANGED
|
Binary file
|
|
@@ -0,0 +1,103 @@
|
|
|
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:: boolean := 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
|
+
if isDisabled then trigger.setAttribute 'aria-disabled', true else trigger.removeAttribute 'aria-disabled'
|
|
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
|
+
disabled = e.currentTarget.closest('[data-item]')?.hasAttribute('data-disabled')
|
|
77
|
+
ARIA.rovingNav e, {
|
|
78
|
+
next: => @_focusNext(1)
|
|
79
|
+
prev: => @_focusNext(-1)
|
|
80
|
+
first: => @_focusTrigger(0)
|
|
81
|
+
last: => @_focusTrigger(-1)
|
|
82
|
+
select: => @toggle(id) unless disabled
|
|
83
|
+
}, 'vertical'
|
|
84
|
+
|
|
85
|
+
_triggers: ->
|
|
86
|
+
return [] unless @_content
|
|
87
|
+
Array.from(@_content.querySelectorAll('[data-trigger]'))
|
|
88
|
+
|
|
89
|
+
_focusNext: (dir) ->
|
|
90
|
+
triggers = @_triggers()
|
|
91
|
+
idx = triggers.indexOf(document.activeElement)
|
|
92
|
+
return if idx is -1
|
|
93
|
+
next = (idx + dir) %% triggers.length
|
|
94
|
+
triggers[next]?.focus()
|
|
95
|
+
|
|
96
|
+
_focusTrigger: (idx) ->
|
|
97
|
+
triggers = @_triggers()
|
|
98
|
+
target = if idx < 0 then triggers[triggers.length - 1] else triggers[idx]
|
|
99
|
+
target?.focus()
|
|
100
|
+
|
|
101
|
+
render
|
|
102
|
+
div ref: "_content"
|
|
103
|
+
slot
|
|
@@ -0,0 +1,53 @@
|
|
|
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
|
+
export AlertDialog = component
|
|
16
|
+
@open:: boolean := false
|
|
17
|
+
@initialFocus:: any := null
|
|
18
|
+
|
|
19
|
+
_prevFocus = null
|
|
20
|
+
_id =! "adlg-#{Math.random().toString(36).slice(2, 8)}"
|
|
21
|
+
|
|
22
|
+
~>
|
|
23
|
+
ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>
|
|
24
|
+
if not isOpen and @open
|
|
25
|
+
@open = false
|
|
26
|
+
@emit 'close'
|
|
27
|
+
), false
|
|
28
|
+
|
|
29
|
+
~>
|
|
30
|
+
if @open
|
|
31
|
+
_prevFocus = document.activeElement
|
|
32
|
+
ARIA.lockScroll(this)
|
|
33
|
+
requestAnimationFrame =>
|
|
34
|
+
panel = @_dialog
|
|
35
|
+
if panel
|
|
36
|
+
ARIA.wireAria panel, _id
|
|
37
|
+
panel.setAttribute 'role', 'alertdialog'
|
|
38
|
+
if @initialFocus
|
|
39
|
+
target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
|
|
40
|
+
target?.focus()
|
|
41
|
+
else
|
|
42
|
+
panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
|
|
43
|
+
return ->
|
|
44
|
+
ARIA.unlockScroll(this)
|
|
45
|
+
_prevFocus?.focus()
|
|
46
|
+
|
|
47
|
+
close: ->
|
|
48
|
+
@open = false
|
|
49
|
+
@emit 'close'
|
|
50
|
+
|
|
51
|
+
render
|
|
52
|
+
dialog ref: "_dialog", $open: @open?!
|
|
53
|
+
slot
|
|
@@ -0,0 +1,115 @@
|
|
|
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:: string := ""
|
|
14
|
+
@items:: any[] := []
|
|
15
|
+
@placeholder:: string := "Type to search..."
|
|
16
|
+
@disabled:: boolean := 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
|
+
@_input?.focus()
|
|
49
|
+
|
|
50
|
+
close: ->
|
|
51
|
+
open = false
|
|
52
|
+
@_hlIdx = -1
|
|
53
|
+
@_input?.focus()
|
|
54
|
+
|
|
55
|
+
_applyPlacement: ->
|
|
56
|
+
ARIA.position @_input, @_list, placement: 'bottom start', offset: 2, matchWidth: true
|
|
57
|
+
|
|
58
|
+
selectIndex: (idx) ->
|
|
59
|
+
item = filteredItems[idx]
|
|
60
|
+
return unless item
|
|
61
|
+
label = if typeof item is 'string' then item else (item.label or item.name or String(item))
|
|
62
|
+
@value = label
|
|
63
|
+
@_input?.value = label
|
|
64
|
+
@emit 'select', item
|
|
65
|
+
@close()
|
|
66
|
+
|
|
67
|
+
onInput: (e) ->
|
|
68
|
+
newVal = e.target.value
|
|
69
|
+
return if newVal is @value
|
|
70
|
+
@value = newVal
|
|
71
|
+
open = true
|
|
72
|
+
@_hlIdx = if filteredItems.length > 0 then 0 else -1
|
|
73
|
+
setTimeout (=> @_updateHighlight()), 0
|
|
74
|
+
|
|
75
|
+
onKeydown: (e) ->
|
|
76
|
+
len = filteredItems.length
|
|
77
|
+
ARIA.listNav e,
|
|
78
|
+
next: => @openMenu() unless open; if len then @_hlIdx = (@_hlIdx + 1) %% len; @_updateHighlight()
|
|
79
|
+
prev: => @openMenu() unless open; if len then @_hlIdx = if @_hlIdx <= 0 then len - 1 else @_hlIdx - 1; @_updateHighlight()
|
|
80
|
+
first: => if len then @_hlIdx = 0; @_updateHighlight()
|
|
81
|
+
last: => if len then @_hlIdx = len - 1; @_updateHighlight()
|
|
82
|
+
select: => @selectIndex(@_hlIdx) if @_hlIdx >= 0
|
|
83
|
+
dismiss: => @close()
|
|
84
|
+
tab: => @close()
|
|
85
|
+
|
|
86
|
+
~>
|
|
87
|
+
if @_list
|
|
88
|
+
@_list.setAttribute 'popover', 'auto'
|
|
89
|
+
@_applyPlacement()
|
|
90
|
+
ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_input)
|
|
91
|
+
|
|
92
|
+
mounted: ->
|
|
93
|
+
@_hlIdx = -1
|
|
94
|
+
@_input.value = @value if @_input and @value
|
|
95
|
+
|
|
96
|
+
render
|
|
97
|
+
. $open: open?!
|
|
98
|
+
|
|
99
|
+
input ref: "_input", role: "combobox", type: "text"
|
|
100
|
+
autocomplete: "off"
|
|
101
|
+
aria-expanded: !!open
|
|
102
|
+
aria-haspopup: "listbox"
|
|
103
|
+
aria-autocomplete: "list"
|
|
104
|
+
aria-controls: open ? _listId : undefined
|
|
105
|
+
$disabled: @disabled?!
|
|
106
|
+
disabled: @disabled
|
|
107
|
+
placeholder: @placeholder
|
|
108
|
+
@input: @onInput
|
|
109
|
+
|
|
110
|
+
div ref: "_list", id: _listId, role: "listbox", $open: open?!, style: "position:fixed;margin:0;inset:auto"
|
|
111
|
+
for item, idx in filteredItems
|
|
112
|
+
div role: "option", tabindex: "-1"
|
|
113
|
+
@click: (=> @selectIndex(idx))
|
|
114
|
+
@mouseenter: (=> @_hlIdx = idx; @_updateHighlight())
|
|
115
|
+
"#{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:: string := ""
|
|
13
|
+
@alt:: string := ""
|
|
14
|
+
@fallback:: string := ""
|
|
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,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" | "outline" | "subtle" := "solid"
|
|
12
|
+
|
|
13
|
+
render
|
|
14
|
+
span $variant: @variant
|
|
15
|
+
slot
|
|
@@ -0,0 +1,47 @@
|
|
|
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:: string := "/"
|
|
19
|
+
@label:: string := "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
|
+
@_content?.style.setProperty '--breadcrumb-separator', JSON.stringify(@separator)
|
|
35
|
+
items.forEach (el, idx) =>
|
|
36
|
+
isLast = idx is items.length - 1
|
|
37
|
+
if isLast
|
|
38
|
+
el.setAttribute 'aria-current', 'page'
|
|
39
|
+
el.toggleAttribute 'data-current', true
|
|
40
|
+
else
|
|
41
|
+
el.removeAttribute 'aria-current'
|
|
42
|
+
el.removeAttribute 'data-current'
|
|
43
|
+
|
|
44
|
+
render
|
|
45
|
+
nav aria-label: @label
|
|
46
|
+
ol ref: "_content"
|
|
47
|
+
slot
|
|
@@ -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" | "vertical" := "horizontal"
|
|
17
|
+
@disabled:: boolean := false
|
|
18
|
+
@label:: string := ""
|
|
19
|
+
|
|
20
|
+
render
|
|
21
|
+
div role: "group"
|
|
22
|
+
aria-label: @label?!
|
|
23
|
+
aria-orientation: @orientation
|
|
24
|
+
$orientation: @orientation
|
|
25
|
+
$disabled: @disabled?!
|
|
26
|
+
slot
|
|
@@ -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:: boolean := 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/docs/ui/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:: boolean := false
|
|
21
|
+
|
|
22
|
+
render
|
|
23
|
+
div tabindex: (if @interactive then "0" else undefined)
|
|
24
|
+
$interactive: @interactive?!
|
|
25
|
+
slot
|
|
@@ -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" | "vertical" := "horizontal"
|
|
22
|
+
@loop:: boolean := false
|
|
23
|
+
@autoplay:: boolean := false
|
|
24
|
+
@interval:: number := 4000
|
|
25
|
+
@label:: string := "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())
|
|
@@ -0,0 +1,61 @@
|
|
|
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:: any[] := []
|
|
15
|
+
@disabled:: boolean := false
|
|
16
|
+
@orientation:: "horizontal" | "vertical" := "vertical"
|
|
17
|
+
@label:: string := ""
|
|
18
|
+
|
|
19
|
+
_options ~=
|
|
20
|
+
return [] unless @_slot
|
|
21
|
+
Array.from(@_slot.querySelectorAll('[data-value]') or [])
|
|
22
|
+
|
|
23
|
+
_toggle: (val) ->
|
|
24
|
+
return if @disabled
|
|
25
|
+
arr = if Array.isArray(@value) then [...@value] else []
|
|
26
|
+
if val in arr
|
|
27
|
+
arr = arr.filter (v) -> v isnt val
|
|
28
|
+
else
|
|
29
|
+
arr.push val
|
|
30
|
+
@value = arr
|
|
31
|
+
@emit 'change', @value
|
|
32
|
+
|
|
33
|
+
onKeydown: (e) ->
|
|
34
|
+
boxes = @_root?.querySelectorAll('[role="checkbox"]')
|
|
35
|
+
return unless boxes?.length
|
|
36
|
+
focused = Array.from(boxes).indexOf(document.activeElement)
|
|
37
|
+
return if focused < 0
|
|
38
|
+
len = boxes.length
|
|
39
|
+
ARIA.rovingNav e, {
|
|
40
|
+
next: => boxes[(focused + 1) %% len]?.focus()
|
|
41
|
+
prev: => boxes[(focused - 1) %% len]?.focus()
|
|
42
|
+
first: => boxes[0]?.focus()
|
|
43
|
+
last: => boxes[len - 1]?.focus()
|
|
44
|
+
}, 'both'
|
|
45
|
+
|
|
46
|
+
render
|
|
47
|
+
div ref: "_root", role: "group", aria-label: @label or undefined, aria-orientation: @orientation
|
|
48
|
+
$orientation: @orientation
|
|
49
|
+
$disabled: @disabled?!
|
|
50
|
+
|
|
51
|
+
. ref: "_slot", style: "display:none"
|
|
52
|
+
slot
|
|
53
|
+
|
|
54
|
+
for opt, idx in _options
|
|
55
|
+
button role: "checkbox", tabindex: (if idx is 0 then "0" else "-1")
|
|
56
|
+
aria-checked: opt.dataset.value in @value
|
|
57
|
+
$checked: (opt.dataset.value in @value)?!
|
|
58
|
+
$disabled: @disabled?!
|
|
59
|
+
$value: opt.dataset.value
|
|
60
|
+
@click: (=> @_toggle(opt.dataset.value))
|
|
61
|
+
= 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:: boolean := false
|
|
16
|
+
@disabled:: boolean := false
|
|
17
|
+
@indeterminate:: boolean := false
|
|
18
|
+
@switch:: boolean := 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
|