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
@@ -0,0 +1,50 @@
1
+ # Collapsible — accessible headless expand/collapse section
2
+ #
3
+ # Single open/close section. Simpler than Accordion (no item IDs,
4
+ # no single/multiple mode). Exposes content dimensions as CSS
5
+ # custom properties for animated expand/collapse. Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Collapsible open <=> isOpen
9
+ # button $trigger: true, "Show details"
10
+ # div $content: true
11
+ # p "Hidden content here"
12
+
13
+ export Collapsible = component
14
+ @open:: boolean := false
15
+ @disabled:: boolean := false
16
+
17
+ _ready := false
18
+
19
+ mounted: ->
20
+ _ready = true
21
+ trigger = @_root?.querySelector('[data-trigger]')
22
+ return unless trigger
23
+ trigger.addEventListener 'click', => @toggle() unless @disabled
24
+ trigger.addEventListener 'keydown', (e) =>
25
+ if e.key in ['Enter', ' '] and not @disabled
26
+ e.preventDefault()
27
+ @toggle()
28
+
29
+ toggle: ->
30
+ @open = not @open
31
+ @emit 'change', @open
32
+
33
+ ~>
34
+ return unless _ready
35
+ trigger = @_root?.querySelector('[data-trigger]')
36
+ content = @_root?.querySelector('[data-content]')
37
+ if trigger
38
+ trigger.setAttribute 'aria-expanded', !!@open
39
+ if @disabled then trigger.setAttribute 'aria-disabled', true else trigger.removeAttribute 'aria-disabled'
40
+ trigger.tabIndex = if @disabled then -1 else 0
41
+ if content
42
+ content.hidden = not @open
43
+ if @open
44
+ rect = content.getBoundingClientRect()
45
+ content.style.setProperty '--collapsible-height', "#{rect.height}px"
46
+ content.style.setProperty '--collapsible-width', "#{rect.width}px"
47
+
48
+ render
49
+ div ref: "_root", $open: @open?!, $disabled: @disabled?!
50
+ slot
@@ -0,0 +1,130 @@
1
+ # Combobox — accessible headless combobox (input + filterable listbox)
2
+ #
3
+ # Keyboard: ArrowDown/Up to navigate, Enter to select, Escape to close,
4
+ # typing filters the list via the @filter callback.
5
+ #
6
+ # Exposes $open on the wrapper, $highlighted on options.
7
+ # Ships zero CSS — style entirely via attribute selectors in your stylesheet.
8
+ #
9
+ # Usage:
10
+ # Combobox query <=> searchText, items: fruits, @select: handleSelect, @filter: filterFn
11
+
12
+ export Combobox = component
13
+ @query:: string := ""
14
+ @items:: any[] := []
15
+ @placeholder:: string := "Search..."
16
+ @disabled:: boolean := false
17
+ @autoHighlight:: boolean := true
18
+
19
+ open := false
20
+ highlightedIndex := -1
21
+ _listId =! "cb-#{Math.random().toString(36).slice(2, 8)}"
22
+
23
+ getItems: ->
24
+ return [] unless @_list
25
+ Array.from(@_list.querySelectorAll('[role="option"]'))
26
+
27
+ _scrollToItem: ->
28
+ @getItems()[highlightedIndex]?.scrollIntoView({ block: 'nearest' })
29
+
30
+ clear: ->
31
+ @query = ''
32
+ highlightedIndex = -1
33
+ @emit 'filter', ''
34
+
35
+ onInput: (e) ->
36
+ @query = e.target.value
37
+ open = true
38
+ highlightedIndex = if @autoHighlight and @items.length > 0 then 0 else -1
39
+ @emit 'filter', @query
40
+
41
+ onFocusin: -> @openMenu()
42
+
43
+ onFocusout: ->
44
+ setTimeout => @close() unless @_content?.contains(document.activeElement), 0
45
+
46
+ openMenu: ->
47
+ open = true
48
+ highlightedIndex = -1
49
+ @_input?.focus()
50
+
51
+ close: ->
52
+ open = false
53
+ highlightedIndex = -1
54
+ @_input?.focus()
55
+
56
+ _applyPlacement: ->
57
+ ARIA.position @_content, @_list, placement: 'bottom start', offset: 2, matchWidth: true
58
+
59
+ isDisabled: (item) -> item?.hasAttribute?('data-disabled')
60
+
61
+ selectIndex: (idx) ->
62
+ item = @getItems()[idx]
63
+ return unless item
64
+ return if @isDisabled(item)
65
+ val = item.dataset.value ?? item.textContent.trim()
66
+ @query = val
67
+ @emit 'select', val
68
+ @close()
69
+
70
+ _nextEnabled: (from, dir) ->
71
+ opts = @getItems()
72
+ len = opts.length
73
+ i = from
74
+ loop len
75
+ i = (i + dir) %% len
76
+ return i unless @isDisabled(opts[i])
77
+ from
78
+
79
+ _onKeydown: (e) ->
80
+ len = @getItems().length
81
+ ARIA.listNav e,
82
+ next: => @openMenu() unless open; highlightedIndex = @_nextEnabled(highlightedIndex, 1); @_scrollToItem()
83
+ prev: => @openMenu() unless open; highlightedIndex = @_nextEnabled(highlightedIndex, -1); @_scrollToItem()
84
+ first: => highlightedIndex = 0; @_scrollToItem()
85
+ last: => highlightedIndex = len - 1; @_scrollToItem()
86
+ select: => if highlightedIndex >= 0 then @selectIndex(highlightedIndex) else if len is 1 then @selectIndex(0)
87
+ dismiss: => if open then @close() else @query = ''
88
+ tab: => @close()
89
+
90
+ ~>
91
+ if @_list
92
+ @_list.setAttribute 'popover', 'auto'
93
+ @_applyPlacement()
94
+ ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_input)
95
+
96
+ render
97
+ . ref: "_content", $open: open?!
98
+
99
+ # Text input
100
+ . style: "position:relative;display:inline-flex;align-items:center"
101
+ input ref: "_input", role: "combobox"
102
+ type: "text"
103
+ autocomplete: "off"
104
+ aria-expanded: !!open
105
+ aria-haspopup: "listbox"
106
+ aria-autocomplete: "list"
107
+ aria-controls: if open then _listId else undefined
108
+ aria-activedescendant: if highlightedIndex >= 0 then "#{_listId}-#{highlightedIndex}" else undefined
109
+ $disabled: @disabled?!
110
+ disabled: @disabled
111
+ placeholder: @placeholder
112
+ value: @query
113
+ @keydown: @_onKeydown
114
+ if @query
115
+ button aria-label: "Clear", $clear: true, @click: @clear
116
+ "✕"
117
+
118
+ # Listbox
119
+ div ref: "_list", id: _listId, role: "listbox", style: "position:fixed;margin:0;inset:auto"
120
+ $open: open?!
121
+ for item, idx in @items
122
+ div role: "option", tabindex: "-1", id: "#{_listId}-#{idx}"
123
+ $value: item
124
+ $highlighted: (idx is highlightedIndex)?!
125
+ @click: (=> @selectIndex(idx))
126
+ @mouseenter: (=> highlightedIndex = idx)
127
+ "#{item}"
128
+ if @items.length is 0 and @query
129
+ div role: "status", aria-live: "polite", $empty: true
130
+ "No results"
@@ -0,0 +1,88 @@
1
+ # ContextMenu — accessible headless right-click menu
2
+ #
3
+ # Opens on contextmenu event (right-click) over the trigger area.
4
+ # Keyboard navigation matches Menu. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # ContextMenu @select: handleAction
8
+ # div $trigger: true
9
+ # p "Right-click this area"
10
+ # div $item: "cut", "Cut"
11
+ # div $item: "copy", "Copy"
12
+ # div $item: "paste", "Paste"
13
+
14
+ export ContextMenu = component
15
+ @disabled:: boolean := false
16
+
17
+ open := false
18
+ highlightedIndex := -1
19
+ posX := 0
20
+ posY := 0
21
+
22
+ _menuItems ~=
23
+ return [] unless @_slot
24
+ Array.from(@_slot.querySelectorAll('[data-item]') or [])
25
+
26
+ _triggerEl ~=
27
+ return null unless @_slot
28
+ @_slot.querySelector('[data-trigger]')
29
+
30
+ _onContextMenu: (e) ->
31
+ return if @disabled
32
+ e.preventDefault()
33
+ posX = e.clientX
34
+ posY = e.clientY
35
+ open = true
36
+ highlightedIndex = 0
37
+ requestAnimationFrame =>
38
+ @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
39
+
40
+ close: ->
41
+ open = false
42
+ highlightedIndex = -1
43
+
44
+ selectIndex: (idx) ->
45
+ item = _menuItems[idx]
46
+ return unless item
47
+ return if item.dataset.disabled?
48
+ @emit 'select', item.dataset.item
49
+ @close()
50
+
51
+ _focusItem: (idx) ->
52
+ highlightedIndex = idx
53
+ @_list?.querySelectorAll('[role="menuitem"]')[idx]?.focus()
54
+
55
+ _onKeydown: (e) ->
56
+ return unless _menuItems.length
57
+ len = _menuItems.length
58
+ ARIA.listNav e,
59
+ next: => @_focusItem((highlightedIndex + 1) %% len)
60
+ prev: => @_focusItem((highlightedIndex - 1) %% len)
61
+ first: => @_focusItem(0)
62
+ last: => @_focusItem(len - 1)
63
+ select: => @selectIndex(highlightedIndex)
64
+ dismiss: => @close()
65
+ tab: => @close()
66
+
67
+ ~> ARIA.popupDismiss open, (=> @_list), (=> @close())
68
+
69
+ render
70
+ . @contextmenu: @_onContextMenu
71
+
72
+ . ref: "_slot", style: "display:none"
73
+ slot
74
+
75
+ if _triggerEl
76
+ . innerHTML: _triggerEl.innerHTML
77
+
78
+ if open
79
+ div ref: "_list", role: "menu", $open: true, @keydown: @_onKeydown
80
+ style: "position:fixed;left:#{posX}px;top:#{posY}px;z-index:50"
81
+ for item, idx in _menuItems
82
+ div role: "menuitem", tabindex: "-1"
83
+ $highlighted: (idx is highlightedIndex)?!
84
+ $disabled: item.dataset.disabled?!
85
+ $value: item.dataset.item
86
+ @click: (=> @selectIndex(idx))
87
+ @mouseenter: (=> highlightedIndex = idx)
88
+ = item.textContent
@@ -0,0 +1,206 @@
1
+ # DatePicker — accessible headless date picker with calendar
2
+ #
3
+ # A popover calendar for selecting a single date or a date range.
4
+ # Set @range to true for range selection (value becomes [from, to]).
5
+ # Keyboard: Arrow keys navigate days, Enter selects, Escape closes.
6
+ # Ships zero CSS — style entirely via attribute selectors in your stylesheet.
7
+ #
8
+ # Usage:
9
+ # DatePicker value <=> selectedDate, placeholder: "Pick a date"
10
+ # DatePicker value <=> dateRange, range: true
11
+
12
+ dpFmt = (d) ->
13
+ return '' unless d
14
+ m = String(d.getMonth() + 1).padStart(2, '0')
15
+ day = String(d.getDate()).padStart(2, '0')
16
+ "#{m}/#{day}/#{d.getFullYear()}"
17
+
18
+ dpParse = (str) ->
19
+ return null unless str?.length is 10
20
+ parts = str.split('/')
21
+ return null unless parts.length is 3
22
+ [m, d, y] = parts.map Number
23
+ return null if isNaN(m) or isNaN(d) or isNaN(y)
24
+ dt = new Date(y, m - 1, d)
25
+ return null if dt.getMonth() isnt m - 1
26
+ dt
27
+
28
+ dpSameDay = (a, b) ->
29
+ return false unless a and b
30
+ a.getFullYear() is b.getFullYear() and a.getMonth() is b.getMonth() and a.getDate() is b.getDate()
31
+
32
+ dpInRange = (day, from, to) ->
33
+ return false unless day and from and to
34
+ t = day.getTime()
35
+ lo = Math.min(from.getTime(), to.getTime())
36
+ hi = Math.max(from.getTime(), to.getTime())
37
+ t >= lo and t <= hi
38
+
39
+ export DatePicker = component
40
+ @value:: any := null
41
+ @placeholder:: string := "mm/dd/yyyy"
42
+ @disabled:: boolean := false
43
+ @range:: boolean := false
44
+ @firstDayOfWeek:: number := 0
45
+
46
+ open := false
47
+ viewMonth := new Date()
48
+ _rangeStart := null
49
+ _hoveredDay := null
50
+ _inputText := ''
51
+ _id =! "dp-#{Math.random().toString(36).slice(2, 8)}"
52
+
53
+ _dayNames =! ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa']
54
+
55
+ _daysInView ~=
56
+ yr = viewMonth.getFullYear()
57
+ mo = viewMonth.getMonth()
58
+ firstOfMonth = new Date(yr, mo, 1)
59
+ startDay = firstOfMonth.getDay()
60
+ offset = (startDay - @firstDayOfWeek + 7) %% 7
61
+ daysInMonth = new Date(yr, mo + 1, 0).getDate()
62
+ prevMonthDays = new Date(yr, mo, 0).getDate()
63
+ dayList = []
64
+ for n in [0...offset]
65
+ dayList.push { date: new Date(yr, mo - 1, prevMonthDays - offset + n + 1), outside: true }
66
+ for n in [1..daysInMonth]
67
+ dayList.push { date: new Date(yr, mo, n), outside: false }
68
+ trailing = (7 - dayList.length %% 7) %% 7
69
+ for n in [1..trailing]
70
+ dayList.push { date: new Date(yr, mo + 1, n), outside: true }
71
+ dayList
72
+
73
+ _displayText ~=
74
+ if @range
75
+ if Array.isArray(@value) and @value[0]
76
+ from = dpFmt(@value[0])
77
+ to = if @value[1] then dpFmt(@value[1]) else '...'
78
+ "#{from} – #{to}"
79
+ else
80
+ @placeholder
81
+ else
82
+ if @value then dpFmt(@value) else @placeholder
83
+
84
+ _monthLabel ~=
85
+ viewMonth.toLocaleDateString(undefined, { month: 'long', year: 'numeric' })
86
+
87
+ _today =! new Date()
88
+
89
+ _prevMonth: ->
90
+ viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() - 1, 1)
91
+
92
+ _nextMonth: ->
93
+ viewMonth = new Date(viewMonth.getFullYear(), viewMonth.getMonth() + 1, 1)
94
+
95
+ _selectDay: (day) ->
96
+ return if @disabled
97
+ if @range
98
+ if _rangeStart and not dpSameDay(_rangeStart, day)
99
+ from = if _rangeStart < day then _rangeStart else day
100
+ to = if _rangeStart < day then day else _rangeStart
101
+ @value = [from, to]
102
+ _rangeStart = null
103
+ @emit 'change', @value
104
+ open = false
105
+ else
106
+ _rangeStart = day
107
+ @value = [day, null]
108
+ else
109
+ @value = day
110
+ @emit 'change', @value
111
+ open = false
112
+ _inputText = _displayText
113
+
114
+ _onInputChange: (e) ->
115
+ raw = e.target.value.replace(/[^\d\/]/g, '')
116
+ if raw.length <= 10
117
+ _inputText = raw
118
+ if raw.length is 10
119
+ dt = dpParse(raw)
120
+ if dt
121
+ @value = dt
122
+ viewMonth = new Date(dt.getFullYear(), dt.getMonth(), 1)
123
+ @emit 'change', @value
124
+
125
+ toggle: ->
126
+ return if @disabled
127
+ if open then @close() else @openPicker()
128
+
129
+ openPicker: ->
130
+ open = true
131
+ if @value and not @range
132
+ viewMonth = new Date(@value.getFullYear(), @value.getMonth(), 1)
133
+ else if @range and Array.isArray(@value) and @value[0]
134
+ viewMonth = new Date(@value[0].getFullYear(), @value[0].getMonth(), 1)
135
+ _inputText = _displayText
136
+ requestAnimationFrame => @_position()
137
+
138
+ close: ->
139
+ open = false
140
+ _rangeStart = null
141
+ _hoveredDay = null
142
+
143
+ _position: -> ARIA.positionBelow @_trigger, @_cal, 4, false
144
+
145
+ _onKeydown: (e) ->
146
+ switch e.key
147
+ when 'Escape'
148
+ e.preventDefault()
149
+ @close()
150
+ @_trigger?.focus()
151
+ when 'Enter', ' '
152
+ e.preventDefault()
153
+ @toggle() unless open
154
+
155
+ ~>
156
+ if open
157
+ onDown = (e) =>
158
+ unless @_trigger?.contains(e.target) or @_cal?.contains(e.target)
159
+ @close()
160
+ document.addEventListener 'mousedown', onDown
161
+ return -> document.removeEventListener 'mousedown', onDown
162
+
163
+ render
164
+ . $open: open?!, $disabled: @disabled?!, $range: @range?!
165
+
166
+ # Trigger
167
+ button ref: "_trigger", $trigger: true
168
+ aria-haspopup: "dialog"
169
+ aria-expanded: !!open
170
+ disabled: @disabled
171
+ @click: @toggle
172
+ @keydown: @_onKeydown
173
+ _displayText
174
+
175
+ # Calendar dropdown
176
+ if open
177
+ div ref: "_cal", role: "dialog", aria-label: "Date picker", $calendar: true
178
+ style: "position:fixed;z-index:50"
179
+
180
+ # Month navigation
181
+ . $header: true
182
+ button $prev: true, aria-label: "Previous month", @click: @_prevMonth
183
+ "‹"
184
+ span $month-label: true
185
+ _monthLabel
186
+ button $next: true, aria-label: "Next month", @click: @_nextMonth
187
+ "›"
188
+
189
+ # Day-of-week headers
190
+ . $weekdays: true
191
+ for dayName in _dayNames
192
+ span $weekday: true
193
+ dayName
194
+
195
+ # Day grid
196
+ . $days: true, role: "grid"
197
+ for entry, dIdx in _daysInView
198
+ button role: "gridcell", tabindex: "-1"
199
+ $outside: entry.outside?!
200
+ $today: dpSameDay(entry.date, _today)?!
201
+ $selected: (if @range then (Array.isArray(@value) and (dpSameDay(entry.date, @value[0]) or dpSameDay(entry.date, @value[1]))) else dpSameDay(entry.date, @value))?!
202
+ $in-range: (if @range then (if _rangeStart then dpInRange(entry.date, _rangeStart, _hoveredDay or _rangeStart) else if Array.isArray(@value) and @value[0] and @value[1] then dpInRange(entry.date, @value[0], @value[1]) else false) else false)?!
203
+ $range-start: (if @range and _rangeStart then dpSameDay(entry.date, _rangeStart) else false)?!
204
+ @click: (=> @_selectDay(entry.date))
205
+ @mouseenter: (=> _hoveredDay = entry.date)
206
+ entry.date.getDate()
@@ -0,0 +1,60 @@
1
+ # Dialog — accessible headless modal dialog
2
+ #
3
+ # Native `<dialog>` variant that uses `showModal()` for top-layer modality.
4
+ # Restores focus to the previously focused element on close.
5
+ # Auto-wires aria-labelledby (first h1-h6) and aria-describedby (first p).
6
+ #
7
+ # Exposes $open on the backdrop. Ships zero CSS.
8
+ #
9
+ # Usage:
10
+ # Dialog open <=> showDialog, @close: handleClose
11
+ # h2 "Title"
12
+ # p "Content"
13
+ # button @click: (=> showDialog = false), "Close"
14
+
15
+ export Dialog = component
16
+ @open:: boolean := false
17
+ @dismissable:: boolean := true
18
+ @initialFocus:: any := null
19
+
20
+ _id =! "dlg-#{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
+ ), @dismissable
28
+
29
+ ~>
30
+ if @open
31
+ ARIA.lockScroll(this)
32
+ requestAnimationFrame =>
33
+ panel = @_dialog
34
+ if panel
35
+ ARIA.wireAria panel, _id
36
+ if @initialFocus
37
+ target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
38
+ target?.focus()
39
+ else
40
+ panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
41
+ return ->
42
+ ARIA.unlockScroll(this)
43
+
44
+ close: ->
45
+ @open = false
46
+ @emit 'close'
47
+
48
+ onKeydown: (e) ->
49
+ if e.key is 'Escape'
50
+ e.preventDefault()
51
+ @close() if @dismissable
52
+
53
+ onBackdropClick: (e) ->
54
+ if e.target is e.currentTarget and @dismissable
55
+ @_dialog?.close()
56
+
57
+ render
58
+ dialog ref: "_dialog", @click: @onBackdropClick, @keydown: @onKeydown
59
+ $open: @open?!
60
+ slot
@@ -0,0 +1,58 @@
1
+ # Drawer — accessible headless slide-out panel
2
+ #
3
+ # A Dialog variant that slides from an edge of the screen.
4
+ # Supports dismiss on escape, click-outside, and optional swipe-to-close.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Drawer open <=> showDrawer, side: "right"
9
+ # h2 "Settings"
10
+ # p "Panel content here"
11
+
12
+ export Drawer = component
13
+ @open:: boolean := false
14
+ @side:: "top" | "right" | "bottom" | "left" := "right"
15
+ @dismissable:: boolean := true
16
+
17
+ _prevFocus = null
18
+ _id =! "drw-#{Math.random().toString(36).slice(2, 8)}"
19
+
20
+ ~>
21
+ ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>
22
+ if not isOpen and @open
23
+ @open = false
24
+ @emit 'close'
25
+ ), @dismissable
26
+
27
+ ~>
28
+ if @open
29
+ _prevFocus = document.activeElement
30
+ ARIA.lockScroll(this)
31
+ requestAnimationFrame =>
32
+ panel = @_dialog
33
+ if panel
34
+ ARIA.wireAria panel, _id
35
+ panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
36
+ return ->
37
+ ARIA.unlockScroll(this)
38
+ _prevFocus?.focus()
39
+
40
+ close: ->
41
+ @open = false
42
+ @emit 'close'
43
+
44
+ onKeydown: (e) ->
45
+ if e.key is 'Escape' and @dismissable
46
+ e.preventDefault()
47
+ @close()
48
+
49
+ onBackdropClick: (e) ->
50
+ if e.target is e.currentTarget and @dismissable
51
+ @_dialog?.close()
52
+
53
+ render
54
+ dialog ref: "_dialog", $open: @open?!, $side: @side
55
+ @click: @onBackdropClick
56
+ @keydown: @onKeydown
57
+ $side: @side
58
+ slot
@@ -0,0 +1,82 @@
1
+ # EditableValue — accessible headless inline editable value
2
+ #
3
+ # Displays a value with an edit trigger. Clicking opens a popover form.
4
+ # Emits 'save' on submit. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # EditableValue @save: handleSave
8
+ # span $display: true
9
+ # "John Doe"
10
+ # div $editor: true
11
+ # input type: "text", value: name, @input: (e) => name = e.target.value
12
+
13
+ export EditableValue = component
14
+ @disabled:: boolean := false
15
+
16
+ editing := false
17
+ saving := false
18
+
19
+ _onEdit: ->
20
+ return if @disabled
21
+ editing = true
22
+ requestAnimationFrame => @_position()
23
+
24
+ _onSave: ->
25
+ return if saving
26
+ saving = true
27
+ @emit 'save'
28
+ @close()
29
+
30
+ _onCancel: ->
31
+ editing = false
32
+ saving = false
33
+
34
+ close: ->
35
+ editing = false
36
+ saving = false
37
+
38
+ setSaving: (val) -> saving = val
39
+
40
+ _position: ->
41
+ display = @_root?.querySelector('[data-display]')
42
+ editor = @_root?.querySelector('[data-editor]')
43
+ return unless display and editor
44
+ @_root.style.position = 'relative'
45
+ dr = display.getBoundingClientRect()
46
+ cr = @_root.getBoundingClientRect()
47
+ editor.style.position = 'absolute'
48
+ editor.style.left = "0px"
49
+ editor.style.top = "#{dr.bottom - cr.top + 4}px"
50
+ editor.style.zIndex = '50'
51
+ editor.querySelector('input, textarea, select')?.focus()
52
+
53
+ ~>
54
+ _editing = editing # track before any early return
55
+ display = @_root?.querySelector('[data-display]')
56
+ editor = @_root?.querySelector('[data-editor]')
57
+ return unless display and editor
58
+ editor.hidden = not _editing
59
+ if _editing
60
+ editor.setAttribute 'data-open', ''
61
+ onDown = (e) =>
62
+ unless @_root?.contains(e.target)
63
+ @_onCancel()
64
+ document.addEventListener 'mousedown', onDown
65
+ return -> document.removeEventListener 'mousedown', onDown
66
+ else
67
+ editor.removeAttribute 'data-open'
68
+
69
+ onKeydown: (e) ->
70
+ if e.key is 'Escape' and editing
71
+ e.preventDefault()
72
+ @_onCancel()
73
+ if e.key is 'Enter' and editing
74
+ e.preventDefault()
75
+ @_onSave()
76
+
77
+ render
78
+ div ref: "_root", $editing: editing?!, $disabled: @disabled?!, $saving: saving?!
79
+ slot
80
+ unless editing
81
+ button $edit-trigger: true, aria-label: "Edit", @click: @_onEdit
82
+ "✎"