@rip-lang/ui 0.3.19 → 0.3.21
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 +443 -576
- package/accordion.rip +113 -0
- package/alert-dialog.rip +96 -0
- package/autocomplete.rip +141 -0
- package/avatar.rip +37 -0
- package/badge.rip +15 -0
- package/breadcrumb.rip +46 -0
- package/button-group.rip +26 -0
- package/button.rip +23 -0
- package/card.rip +25 -0
- package/carousel.rip +110 -0
- package/checkbox-group.rip +65 -0
- package/checkbox.rip +33 -0
- package/collapsible.rip +50 -0
- package/combobox.rip +155 -0
- package/context-menu.rip +105 -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 +16 -0
- package/input-group.rip +28 -0
- package/input.rip +36 -0
- package/label.rip +16 -0
- package/menu.rip +162 -0
- package/menubar.rip +155 -0
- package/meter.rip +36 -0
- package/multi-select.rip +158 -0
- package/native-select.rip +32 -0
- package/nav-menu.rip +129 -0
- package/number-field.rip +162 -0
- package/otp-field.rip +89 -0
- package/package.json +18 -27
- package/pagination.rip +123 -0
- package/popover.rip +143 -0
- package/preview-card.rip +73 -0
- package/progress.rip +25 -0
- package/radio-group.rip +67 -0
- package/resizable.rip +123 -0
- package/scroll-area.rip +145 -0
- package/select.rip +184 -0
- package/separator.rip +17 -0
- package/skeleton.rip +22 -0
- package/slider.rip +165 -0
- package/spinner.rip +17 -0
- package/table.rip +27 -0
- package/tabs.rip +124 -0
- package/textarea.rip +48 -0
- package/toast.rip +87 -0
- package/toggle-group.rip +78 -0
- package/toggle.rip +24 -0
- package/toolbar.rip +46 -0
- package/tooltip.rip +115 -0
- package/dist/rip-ui.min.js +0 -524
- package/dist/rip-ui.min.js.br +0 -0
- package/serve.rip +0 -92
- package/ui.rip +0 -964
|
@@ -0,0 +1,65 @@
|
|
|
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 := []
|
|
15
|
+
@disabled := false
|
|
16
|
+
@orientation := 'vertical'
|
|
17
|
+
@label := ''
|
|
18
|
+
|
|
19
|
+
_options ~=
|
|
20
|
+
return [] unless @_slot
|
|
21
|
+
Array.from(@_slot.querySelectorAll('[data-value]') or [])
|
|
22
|
+
|
|
23
|
+
_isChecked: (val) ->
|
|
24
|
+
Array.isArray(@value) and val in @value
|
|
25
|
+
|
|
26
|
+
_toggle: (val) ->
|
|
27
|
+
return if @disabled
|
|
28
|
+
arr = if Array.isArray(@value) then [...@value] else []
|
|
29
|
+
if val in arr
|
|
30
|
+
arr = arr.filter (v) -> v isnt val
|
|
31
|
+
else
|
|
32
|
+
arr.push val
|
|
33
|
+
@value = arr
|
|
34
|
+
@emit 'change', @value
|
|
35
|
+
|
|
36
|
+
onKeydown: (e) ->
|
|
37
|
+
boxes = @_root?.querySelectorAll('[role="checkbox"]')
|
|
38
|
+
return unless boxes?.length
|
|
39
|
+
focused = Array.from(boxes).indexOf(document.activeElement)
|
|
40
|
+
return if focused < 0
|
|
41
|
+
len = boxes.length
|
|
42
|
+
switch e.key
|
|
43
|
+
when 'ArrowDown', 'ArrowRight'
|
|
44
|
+
e.preventDefault()
|
|
45
|
+
boxes[(focused + 1) %% len]?.focus()
|
|
46
|
+
when 'ArrowUp', 'ArrowLeft'
|
|
47
|
+
e.preventDefault()
|
|
48
|
+
boxes[(focused - 1) %% len]?.focus()
|
|
49
|
+
|
|
50
|
+
render
|
|
51
|
+
div ref: "_root", role: "group", aria-label: @label or undefined, aria-orientation: @orientation
|
|
52
|
+
$orientation: @orientation
|
|
53
|
+
$disabled: @disabled?!
|
|
54
|
+
|
|
55
|
+
. ref: "_slot", style: "display:none"
|
|
56
|
+
slot
|
|
57
|
+
|
|
58
|
+
for opt, idx in _options
|
|
59
|
+
button role: "checkbox", tabindex: (if idx is 0 then "0" else "-1")
|
|
60
|
+
aria-checked: !!@_isChecked(opt.dataset.value)
|
|
61
|
+
$checked: @_isChecked(opt.dataset.value)?!
|
|
62
|
+
$disabled: @disabled?!
|
|
63
|
+
$value: opt.dataset.value
|
|
64
|
+
@click: (=> @_toggle(opt.dataset.value))
|
|
65
|
+
= opt.textContent
|
package/checkbox.rip
ADDED
|
@@ -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 := false
|
|
16
|
+
@disabled := false
|
|
17
|
+
@indeterminate := false
|
|
18
|
+
@switch := 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
|
package/collapsible.rip
ADDED
|
@@ -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 := false
|
|
15
|
+
@disabled := 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
|
+
trigger.setAttribute 'aria-disabled', true if @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
|
package/combobox.rip
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
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 := ''
|
|
14
|
+
@items := []
|
|
15
|
+
@placeholder := 'Search...'
|
|
16
|
+
@disabled := false
|
|
17
|
+
@autoHighlight := 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: (e) ->
|
|
44
|
+
unless @_content?.contains(e.relatedTarget)
|
|
45
|
+
@close()
|
|
46
|
+
|
|
47
|
+
openMenu: ->
|
|
48
|
+
open = true
|
|
49
|
+
highlightedIndex = -1
|
|
50
|
+
setTimeout => @_position(), 0
|
|
51
|
+
|
|
52
|
+
close: ->
|
|
53
|
+
open = false
|
|
54
|
+
highlightedIndex = -1
|
|
55
|
+
|
|
56
|
+
_position: ->
|
|
57
|
+
return unless @_input and @_list
|
|
58
|
+
tr = @_input.getBoundingClientRect()
|
|
59
|
+
@_list.style.left = "#{tr.left}px"
|
|
60
|
+
@_list.style.top = "#{tr.bottom + 2}px"
|
|
61
|
+
@_list.style.minWidth = "#{tr.width}px"
|
|
62
|
+
fl = @_list.getBoundingClientRect()
|
|
63
|
+
if fl.bottom > window.innerHeight
|
|
64
|
+
@_list.style.top = "#{tr.top - fl.height - 2}px"
|
|
65
|
+
|
|
66
|
+
isDisabled: (item) -> item?.hasAttribute?('data-disabled')
|
|
67
|
+
|
|
68
|
+
selectIndex: (idx) ->
|
|
69
|
+
item = @getItems()[idx]
|
|
70
|
+
return unless item
|
|
71
|
+
return if @isDisabled(item)
|
|
72
|
+
val = item.dataset.value ?? item.textContent.trim()
|
|
73
|
+
@query = val
|
|
74
|
+
@emit 'select', val
|
|
75
|
+
@close()
|
|
76
|
+
@_input?.blur()
|
|
77
|
+
|
|
78
|
+
_nextEnabled: (from, dir) ->
|
|
79
|
+
opts = @getItems()
|
|
80
|
+
len = opts.length
|
|
81
|
+
i = from
|
|
82
|
+
loop len
|
|
83
|
+
i = (i + dir) %% len
|
|
84
|
+
return i unless @isDisabled(opts[i])
|
|
85
|
+
from
|
|
86
|
+
|
|
87
|
+
_onKeydown: (e) ->
|
|
88
|
+
len = @getItems().length
|
|
89
|
+
switch e.key
|
|
90
|
+
when 'ArrowDown'
|
|
91
|
+
e.preventDefault()
|
|
92
|
+
@openMenu() unless open
|
|
93
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, 1)
|
|
94
|
+
@_scrollToItem()
|
|
95
|
+
when 'ArrowUp'
|
|
96
|
+
e.preventDefault()
|
|
97
|
+
highlightedIndex = @_nextEnabled(highlightedIndex, -1)
|
|
98
|
+
@_scrollToItem()
|
|
99
|
+
when 'Enter'
|
|
100
|
+
e.preventDefault()
|
|
101
|
+
if highlightedIndex >= 0
|
|
102
|
+
@selectIndex(highlightedIndex)
|
|
103
|
+
else if len is 1
|
|
104
|
+
@selectIndex(0)
|
|
105
|
+
when 'Escape'
|
|
106
|
+
e.preventDefault()
|
|
107
|
+
if open then @close() else @query = ''
|
|
108
|
+
when 'Tab'
|
|
109
|
+
@close()
|
|
110
|
+
|
|
111
|
+
~>
|
|
112
|
+
if open
|
|
113
|
+
onDown = (e) =>
|
|
114
|
+
root = @_content
|
|
115
|
+
unless root?.contains(e.target)
|
|
116
|
+
@close()
|
|
117
|
+
document.addEventListener 'mousedown', onDown
|
|
118
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
119
|
+
|
|
120
|
+
render
|
|
121
|
+
. ref: "_content", $open: open?!
|
|
122
|
+
|
|
123
|
+
# Text input
|
|
124
|
+
. style: "position:relative;display:inline-flex;align-items:center"
|
|
125
|
+
input ref: "_input", role: "combobox"
|
|
126
|
+
type: "text"
|
|
127
|
+
autocomplete: "off"
|
|
128
|
+
aria-expanded: !!open
|
|
129
|
+
aria-haspopup: "listbox"
|
|
130
|
+
aria-autocomplete: "list"
|
|
131
|
+
aria-controls: if open then _listId else undefined
|
|
132
|
+
aria-activedescendant: if highlightedIndex >= 0 then "#{_listId}-#{highlightedIndex}" else undefined
|
|
133
|
+
$disabled: @disabled?!
|
|
134
|
+
disabled: @disabled
|
|
135
|
+
placeholder: @placeholder
|
|
136
|
+
value: @query
|
|
137
|
+
@keydown: @_onKeydown
|
|
138
|
+
if @query
|
|
139
|
+
button aria-label: "Clear", $clear: true, @click: @clear
|
|
140
|
+
"✕"
|
|
141
|
+
|
|
142
|
+
# Listbox — conditionally rendered (like Select)
|
|
143
|
+
if open and @items.length > 0
|
|
144
|
+
div ref: "_list", id: _listId, role: "listbox", $open: true
|
|
145
|
+
style: "position:fixed"
|
|
146
|
+
for item, idx in @items
|
|
147
|
+
div role: "option", tabindex: "-1", id: "#{_listId}-#{idx}"
|
|
148
|
+
$value: item
|
|
149
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
150
|
+
@click: (=> @selectIndex(idx))
|
|
151
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
152
|
+
"#{item}"
|
|
153
|
+
if open and @items.length is 0 and @query
|
|
154
|
+
div role: "status", aria-live: "polite", $empty: true
|
|
155
|
+
"No results"
|
package/context-menu.rip
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
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 := 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
|
+
_onKeydown: (e) ->
|
|
52
|
+
len = _menuItems.length
|
|
53
|
+
return unless len
|
|
54
|
+
switch e.key
|
|
55
|
+
when 'ArrowDown'
|
|
56
|
+
e.preventDefault()
|
|
57
|
+
highlightedIndex = (highlightedIndex + 1) %% len
|
|
58
|
+
@_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
|
|
59
|
+
when 'ArrowUp'
|
|
60
|
+
e.preventDefault()
|
|
61
|
+
highlightedIndex = (highlightedIndex - 1) %% len
|
|
62
|
+
@_list?.querySelectorAll('[role="menuitem"]')[highlightedIndex]?.focus()
|
|
63
|
+
when 'Home'
|
|
64
|
+
e.preventDefault()
|
|
65
|
+
highlightedIndex = 0
|
|
66
|
+
@_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
|
|
67
|
+
when 'End'
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
highlightedIndex = len - 1
|
|
70
|
+
@_list?.querySelectorAll('[role="menuitem"]')[len - 1]?.focus()
|
|
71
|
+
when 'Enter', ' '
|
|
72
|
+
e.preventDefault()
|
|
73
|
+
@selectIndex(highlightedIndex)
|
|
74
|
+
when 'Escape', 'Tab'
|
|
75
|
+
e.preventDefault() if e.key is 'Escape'
|
|
76
|
+
@close()
|
|
77
|
+
|
|
78
|
+
~>
|
|
79
|
+
if open
|
|
80
|
+
onDown = (e) =>
|
|
81
|
+
unless @_list?.contains(e.target)
|
|
82
|
+
@close()
|
|
83
|
+
document.addEventListener 'mousedown', onDown
|
|
84
|
+
return -> document.removeEventListener 'mousedown', onDown
|
|
85
|
+
|
|
86
|
+
render
|
|
87
|
+
. @contextmenu: @_onContextMenu
|
|
88
|
+
|
|
89
|
+
. ref: "_slot", style: "display:none"
|
|
90
|
+
slot
|
|
91
|
+
|
|
92
|
+
if _triggerEl
|
|
93
|
+
. innerHTML: _triggerEl.innerHTML
|
|
94
|
+
|
|
95
|
+
if open
|
|
96
|
+
div ref: "_list", role: "menu", $open: true, @keydown: @_onKeydown
|
|
97
|
+
style: "position:fixed;left:#{posX}px;top:#{posY}px;z-index:50"
|
|
98
|
+
for item, idx in _menuItems
|
|
99
|
+
div role: "menuitem", tabindex: "-1"
|
|
100
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
101
|
+
$disabled: item.dataset.disabled?!
|
|
102
|
+
$value: item.dataset.item
|
|
103
|
+
@click: (=> @selectIndex(idx))
|
|
104
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
105
|
+
= item.textContent
|
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()
|