@rip-lang/ui 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,214 @@
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 := null
41
+ @placeholder := 'mm/dd/yyyy'
42
+ @disabled := false
43
+ @range := false
44
+ @firstDayOfWeek := 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
+ setTimeout => @_position(), 0
137
+
138
+ close: ->
139
+ open = false
140
+ _rangeStart = null
141
+ _hoveredDay = null
142
+
143
+ _position: ->
144
+ return unless @_trigger and @_cal
145
+ tr = @_trigger.getBoundingClientRect()
146
+ @_cal.style.position = 'fixed'
147
+ @_cal.style.left = "#{tr.left}px"
148
+ @_cal.style.top = "#{tr.bottom + 4}px"
149
+ fl = @_cal.getBoundingClientRect()
150
+ if fl.bottom > window.innerHeight
151
+ @_cal.style.top = "#{tr.top - fl.height - 4}px"
152
+
153
+ _onKeydown: (e) ->
154
+ switch e.key
155
+ when 'Escape'
156
+ e.preventDefault()
157
+ @close()
158
+ @_trigger?.focus()
159
+ when 'Enter', ' '
160
+ e.preventDefault()
161
+ @toggle() unless open
162
+
163
+ ~>
164
+ if open
165
+ onDown = (e) =>
166
+ unless @_trigger?.contains(e.target) or @_cal?.contains(e.target)
167
+ @close()
168
+ document.addEventListener 'mousedown', onDown
169
+ return -> document.removeEventListener 'mousedown', onDown
170
+
171
+ render
172
+ . $open: open?!, $disabled: @disabled?!, $range: @range?!
173
+
174
+ # Trigger
175
+ button ref: "_trigger", $trigger: true
176
+ aria-haspopup: "dialog"
177
+ aria-expanded: !!open
178
+ disabled: @disabled
179
+ @click: @toggle
180
+ @keydown: @_onKeydown
181
+ _displayText
182
+
183
+ # Calendar dropdown
184
+ if open
185
+ div ref: "_cal", role: "dialog", aria-label: "Date picker", $calendar: true
186
+ style: "position:fixed;z-index:50"
187
+
188
+ # Month navigation
189
+ . $header: true
190
+ button $prev: true, aria-label: "Previous month", @click: @_prevMonth
191
+ "‹"
192
+ span $month-label: true
193
+ _monthLabel
194
+ button $next: true, aria-label: "Next month", @click: @_nextMonth
195
+ "›"
196
+
197
+ # Day-of-week headers
198
+ . $weekdays: true
199
+ for dayName in _dayNames
200
+ span $weekday: true
201
+ dayName
202
+
203
+ # Day grid
204
+ . $days: true, role: "grid"
205
+ for entry, dIdx in _daysInView
206
+ button role: "gridcell", tabindex: "-1"
207
+ $outside: entry.outside?!
208
+ $today: dpSameDay(entry.date, _today)?!
209
+ $selected: (if @range then (Array.isArray(@value) and (dpSameDay(entry.date, @value[0]) or dpSameDay(entry.date, @value[1]))) else dpSameDay(entry.date, @value))?!
210
+ $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)?!
211
+ $range-start: (if @range and _rangeStart then dpSameDay(entry.date, _rangeStart) else false)?!
212
+ @click: (=> @_selectDay(entry.date))
213
+ @mouseenter: (=> _hoveredDay = entry.date)
214
+ entry.date.getDate()
package/dialog.rip ADDED
@@ -0,0 +1,107 @@
1
+ # Dialog — accessible headless modal dialog
2
+ #
3
+ # Traps focus, locks scroll, dismisses on Escape or click outside.
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
+ dialogStack = []
16
+
17
+ export Dialog = component
18
+ @open := false
19
+ @dismissable := true
20
+ @initialFocus := null
21
+
22
+ _prevFocus = null
23
+ _cleanupTrap = null
24
+ _scrollY = 0
25
+ _id =! "dlg-#{Math.random().toString(36).slice(2, 8)}"
26
+
27
+ _wireAria: ->
28
+ panel = @_panel
29
+ return unless panel
30
+ heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
31
+ if heading
32
+ heading.id ?= "#{_id}-title"
33
+ panel.setAttribute 'aria-labelledby', heading.id
34
+ desc = panel.querySelector('p')
35
+ if desc
36
+ desc.id ?= "#{_id}-desc"
37
+ panel.setAttribute 'aria-describedby', desc.id
38
+
39
+ ~>
40
+ if @open
41
+ _prevFocus = document.activeElement
42
+ _scrollY = window.scrollY
43
+ dialogStack.push this
44
+ document.body.style.position = 'fixed'
45
+ document.body.style.top = "-#{_scrollY}px"
46
+ document.body.style.width = '100%'
47
+
48
+ setTimeout =>
49
+ panel = @_panel
50
+ if panel
51
+ @_wireAria()
52
+ if @initialFocus
53
+ target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
54
+ target?.focus()
55
+ else
56
+ focusable = panel.querySelectorAll 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
57
+ focusable[0]?.focus()
58
+ _cleanupTrap = (e) ->
59
+ return unless e.key is 'Tab'
60
+ 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
61
+ return unless list.length
62
+ first = list[0]
63
+ last = list[list.length - 1]
64
+ if e.shiftKey
65
+ if document.activeElement is first then (e.preventDefault(); last.focus())
66
+ else
67
+ if document.activeElement is last then (e.preventDefault(); first.focus())
68
+ panel.addEventListener 'keydown', _cleanupTrap
69
+ , 0
70
+
71
+ return ->
72
+ idx = dialogStack.indexOf this
73
+ dialogStack.splice(idx, 1) if idx >= 0
74
+ document.body.style.position = '' unless dialogStack.length
75
+ document.body.style.top = '' unless dialogStack.length
76
+ document.body.style.width = '' unless dialogStack.length
77
+ window.scrollTo 0, _scrollY unless dialogStack.length
78
+ _prevFocus?.focus()
79
+ else
80
+ idx = dialogStack.indexOf this
81
+ dialogStack.splice(idx, 1) if idx >= 0
82
+ unless dialogStack.length
83
+ document.body.style.position = ''
84
+ document.body.style.top = ''
85
+ document.body.style.width = ''
86
+ window.scrollTo 0, _scrollY
87
+ _prevFocus?.focus()
88
+
89
+ close: ->
90
+ @open = false
91
+ @emit 'close'
92
+
93
+ onKeydown: (e) ->
94
+ if e.key is 'Escape' and dialogStack[dialogStack.length - 1] is this
95
+ e.preventDefault()
96
+ @close() if @dismissable
97
+
98
+ onBackdropClick: (e) ->
99
+ @close() if e.target is e.currentTarget and @dismissable
100
+
101
+ render
102
+ if @open
103
+ div ref: "_backdrop", $open: true,
104
+ @click: @onBackdropClick,
105
+ @keydown: @onKeydown
106
+ div ref: "_panel", role: "dialog", aria-modal: "true", tabindex: "-1"
107
+ slot
package/drawer.rip ADDED
@@ -0,0 +1,79 @@
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 := false
14
+ @side := 'right'
15
+ @dismissable := true
16
+
17
+ _prevFocus = null
18
+ _scrollY = 0
19
+ _id =! "drw-#{Math.random().toString(36).slice(2, 8)}"
20
+
21
+ _wireAria: ->
22
+ panel = @_panel
23
+ return unless panel
24
+ heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
25
+ if heading
26
+ heading.id ?= "#{_id}-title"
27
+ panel.setAttribute 'aria-labelledby', heading.id
28
+ desc = panel.querySelector('p')
29
+ if desc
30
+ desc.id ?= "#{_id}-desc"
31
+ panel.setAttribute 'aria-describedby', desc.id
32
+
33
+ ~>
34
+ if @open
35
+ _prevFocus = document.activeElement
36
+ document.body.style.overflow = 'hidden'
37
+
38
+ setTimeout =>
39
+ panel = @_panel
40
+ if panel
41
+ @_wireAria()
42
+ focusable = panel.querySelectorAll 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
43
+ focusable[0]?.focus()
44
+ @_trapHandler = (e) ->
45
+ return unless e.key is 'Tab'
46
+ 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
47
+ return unless list.length
48
+ first = list[0]
49
+ last = list[list.length - 1]
50
+ if e.shiftKey
51
+ if document.activeElement is first then (e.preventDefault(); last.focus())
52
+ else
53
+ if document.activeElement is last then (e.preventDefault(); first.focus())
54
+ panel.addEventListener 'keydown', @_trapHandler
55
+ , 0
56
+ else
57
+ document.body.style.overflow = ''
58
+ _prevFocus?.focus()
59
+
60
+ close: ->
61
+ @open = false
62
+ @emit 'close'
63
+
64
+ onKeydown: (e) ->
65
+ if e.key is 'Escape' and @dismissable
66
+ e.preventDefault()
67
+ @close()
68
+
69
+ onBackdropClick: (e) ->
70
+ @close() if e.target is e.currentTarget and @dismissable
71
+
72
+ render
73
+ if @open
74
+ div ref: "_backdrop", $open: true, $side: @side
75
+ @click: @onBackdropClick
76
+ @keydown: @onKeydown
77
+ div ref: "_panel", role: "dialog", aria-modal: "true", tabindex: "-1"
78
+ $side: @side
79
+ slot
@@ -0,0 +1,80 @@
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 := 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
+
29
+ _onCancel: ->
30
+ editing = false
31
+ saving = false
32
+
33
+ close: ->
34
+ editing = false
35
+ saving = false
36
+
37
+ setSaving: (val) -> saving = val
38
+
39
+ _position: ->
40
+ display = @_root?.querySelector('[data-display]')
41
+ editor = @_root?.querySelector('[data-editor]')
42
+ return unless display and editor
43
+ @_root.style.position = 'relative'
44
+ dr = display.getBoundingClientRect()
45
+ cr = @_root.getBoundingClientRect()
46
+ editor.style.position = 'absolute'
47
+ editor.style.left = "0px"
48
+ editor.style.top = "#{dr.bottom - cr.top + 4}px"
49
+ editor.style.zIndex = '50'
50
+ editor.querySelector('input, textarea, select')?.focus()
51
+
52
+ ~>
53
+ display = @_root?.querySelector('[data-display]')
54
+ editor = @_root?.querySelector('[data-editor]')
55
+ return unless display and editor
56
+ editor.hidden = not editing
57
+ if editing
58
+ editor.setAttribute 'data-open', ''
59
+ onDown = (e) =>
60
+ unless @_root?.contains(e.target)
61
+ @_onCancel()
62
+ document.addEventListener 'mousedown', onDown
63
+ return -> document.removeEventListener 'mousedown', onDown
64
+ else
65
+ editor.removeAttribute 'data-open'
66
+
67
+ onKeydown: (e) ->
68
+ if e.key is 'Escape' and editing
69
+ e.preventDefault()
70
+ @_onCancel()
71
+ if e.key is 'Enter' and editing
72
+ e.preventDefault()
73
+ @_onSave()
74
+
75
+ render
76
+ div ref: "_root", $editing: editing?!, $disabled: @disabled?!, $saving: saving?!
77
+ slot
78
+ unless editing
79
+ button $edit-trigger: true, aria-label: "Edit", @click: @_onEdit
80
+ "✎"
package/field.rip ADDED
@@ -0,0 +1,53 @@
1
+ # Field — accessible headless form field wrapper
2
+ #
3
+ # Associates a label, description, and error message with a form control.
4
+ # Generates linked IDs for aria-labelledby/describedby/errormessage.
5
+ # Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Field label: "Email", error: errors.email
9
+ # Input value <=> email, type: "email"
10
+
11
+ export Field = component
12
+ @label := ''
13
+ @description := ''
14
+ @error := ''
15
+ @disabled := false
16
+ @required := false
17
+
18
+ _id =! "fld-#{Math.random().toString(36).slice(2, 8)}"
19
+
20
+ mounted: ->
21
+ ctrl = @_root?.querySelector('input, select, textarea, button, [role]')
22
+ if ctrl
23
+ ctrl.setAttribute 'aria-labelledby', "#{_id}-label" if @label
24
+ ctrl.setAttribute 'aria-describedby', "#{_id}-desc" if @description
25
+ ctrl.setAttribute 'aria-errormessage', "#{_id}-err" if @error
26
+ ctrl.setAttribute 'aria-invalid', true if @error
27
+ ctrl.setAttribute 'aria-required', true if @required
28
+
29
+ ~>
30
+ ctrl = @_root?.querySelector('input, select, textarea, button, [role]')
31
+ return unless ctrl
32
+ if @error
33
+ ctrl.setAttribute 'aria-invalid', true
34
+ ctrl.setAttribute 'aria-errormessage', "#{_id}-err"
35
+ else
36
+ ctrl.removeAttribute 'aria-invalid'
37
+ ctrl.removeAttribute 'aria-errormessage'
38
+
39
+ render
40
+ div $disabled: @disabled?!, $invalid: @error?!
41
+ if @label
42
+ label id: "#{_id}-label", $label: true
43
+ @label
44
+ if @required
45
+ span $required: true, aria-hidden: "true"
46
+ " *"
47
+ slot
48
+ if @description and not @error
49
+ div id: "#{_id}-desc", $description: true
50
+ @description
51
+ if @error
52
+ div id: "#{_id}-err", role: "alert", $error: true
53
+ @error
package/fieldset.rip ADDED
@@ -0,0 +1,22 @@
1
+ # Fieldset — accessible headless fieldset with legend
2
+ #
3
+ # Groups related fields with an optional legend. Disables all children
4
+ # when @disabled is set. Ships zero CSS.
5
+ #
6
+ # Usage:
7
+ # Fieldset legend: "Shipping Address"
8
+ # Field label: "Street"
9
+ # Input value <=> street
10
+ # Field label: "City"
11
+ # Input value <=> city
12
+
13
+ export Fieldset = component
14
+ @legend := ''
15
+ @disabled := false
16
+
17
+ render
18
+ fieldset disabled: @disabled, $disabled: @disabled?!
19
+ if @legend
20
+ legend $legend: true
21
+ @legend
22
+ slot
package/form.rip ADDED
@@ -0,0 +1,39 @@
1
+ # Form — accessible headless form with validation and submission
2
+ #
3
+ # Wraps native <form> with submit handling, validation state, and
4
+ # loading indicator support. Prevents default submission and emits
5
+ # a 'submit' event. Ships zero CSS.
6
+ #
7
+ # Usage:
8
+ # Form @submit: handleSubmit
9
+ # Field label: "Name"
10
+ # Input value <=> name
11
+ # Button
12
+ # "Submit"
13
+
14
+ export Form = component
15
+ @disabled := false
16
+
17
+ submitting := false
18
+ submitted := false
19
+ errors := {}
20
+
21
+ _onSubmit: (e) ->
22
+ e.preventDefault()
23
+ return if @disabled or submitting
24
+ submitting = true
25
+ submitted = true
26
+ @emit 'submit', { form: e.target }
27
+
28
+ setErrors: (errs) ->
29
+ errors = errs or {}
30
+
31
+ setSubmitting: (val) ->
32
+ submitting = val
33
+
34
+ render
35
+ form @submit: @_onSubmit, novalidate: true
36
+ $disabled: @disabled?!
37
+ $submitting: submitting?!
38
+ $submitted: submitted?!
39
+ slot