@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.
- package/README.md +587 -137
- package/accordion.rip +113 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/button.rip +23 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/combobox.rip +153 -0
- package/context-menu.rip +98 -0
- package/date-picker.rip +214 -0
- package/dialog.rip +107 -0
- package/drawer.rip +79 -0
- package/editable-value.rip +80 -0
- package/field.rip +53 -0
- package/fieldset.rip +22 -0
- package/form.rip +39 -0
- package/grid.rip +901 -0
- package/index.rip +15 -0
- package/input.rip +35 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/nav-menu.rip +132 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +16 -26
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/slider.rip +165 -0
- package/tabs.rip +124 -0
- package/toast.rip +88 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/serve.rip +0 -140
- package/ui.rip +0 -935
package/date-picker.rip
ADDED
|
@@ -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
|