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.
Files changed (71) hide show
  1. package/README.md +11 -2
  2. package/docs/RIP-LANG.md +4 -0
  3. package/docs/dist/rip.js +257 -27
  4. package/docs/dist/rip.min.js +183 -183
  5. package/docs/dist/rip.min.js.br +0 -0
  6. package/docs/ui/accordion.rip +103 -0
  7. package/docs/ui/alert-dialog.rip +53 -0
  8. package/docs/ui/autocomplete.rip +115 -0
  9. package/docs/ui/avatar.rip +37 -0
  10. package/docs/ui/badge.rip +15 -0
  11. package/docs/ui/breadcrumb.rip +47 -0
  12. package/docs/ui/button-group.rip +26 -0
  13. package/docs/ui/button.rip +23 -0
  14. package/docs/ui/card.rip +25 -0
  15. package/docs/ui/carousel.rip +110 -0
  16. package/docs/ui/checkbox-group.rip +61 -0
  17. package/docs/ui/checkbox.rip +33 -0
  18. package/docs/ui/collapsible.rip +50 -0
  19. package/docs/ui/combobox.rip +130 -0
  20. package/docs/ui/context-menu.rip +88 -0
  21. package/docs/ui/date-picker.rip +206 -0
  22. package/docs/ui/dialog.rip +60 -0
  23. package/docs/ui/drawer.rip +58 -0
  24. package/docs/ui/editable-value.rip +82 -0
  25. package/docs/ui/field.rip +53 -0
  26. package/docs/ui/fieldset.rip +22 -0
  27. package/docs/ui/form.rip +39 -0
  28. package/docs/ui/grid.rip +901 -0
  29. package/docs/ui/hljs-rip.js +209 -0
  30. package/docs/ui/index.css +1797 -0
  31. package/docs/ui/index.html +2385 -0
  32. package/docs/ui/input-group.rip +28 -0
  33. package/docs/ui/input.rip +36 -0
  34. package/docs/ui/label.rip +16 -0
  35. package/docs/ui/menu.rip +134 -0
  36. package/docs/ui/menubar.rip +151 -0
  37. package/docs/ui/meter.rip +36 -0
  38. package/docs/ui/multi-select.rip +203 -0
  39. package/docs/ui/native-select.rip +33 -0
  40. package/docs/ui/nav-menu.rip +126 -0
  41. package/docs/ui/number-field.rip +162 -0
  42. package/docs/ui/otp-field.rip +89 -0
  43. package/docs/ui/pagination.rip +123 -0
  44. package/docs/ui/popover.rip +93 -0
  45. package/docs/ui/preview-card.rip +75 -0
  46. package/docs/ui/progress.rip +25 -0
  47. package/docs/ui/radio-group.rip +57 -0
  48. package/docs/ui/resizable.rip +123 -0
  49. package/docs/ui/scroll-area.rip +145 -0
  50. package/docs/ui/select.rip +151 -0
  51. package/docs/ui/separator.rip +17 -0
  52. package/docs/ui/skeleton.rip +22 -0
  53. package/docs/ui/slider.rip +165 -0
  54. package/docs/ui/spinner.rip +17 -0
  55. package/docs/ui/table.rip +27 -0
  56. package/docs/ui/tabs.rip +113 -0
  57. package/docs/ui/textarea.rip +48 -0
  58. package/docs/ui/toast.rip +87 -0
  59. package/docs/ui/toggle-group.rip +71 -0
  60. package/docs/ui/toggle.rip +24 -0
  61. package/docs/ui/toolbar.rip +38 -0
  62. package/docs/ui/tooltip.rip +85 -0
  63. package/package.json +1 -1
  64. package/src/compiler.js +24 -12
  65. package/src/components.js +43 -6
  66. package/src/grammar/grammar.rip +2 -2
  67. package/src/lexer.js +26 -0
  68. package/src/parser.js +2 -2
  69. package/src/sourcemap-utils.js +91 -0
  70. package/src/typecheck.js +33 -8
  71. package/src/ui.rip +118 -2
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
@@ -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