@rip-lang/ui 0.3.54 → 0.3.56
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 +3 -0
- package/components/alert-dialog.rip +11 -8
- package/components/dialog.rip +15 -13
- package/components/drawer.rip +17 -14
- package/components/menu.rip +30 -17
- package/components/popover.rip +43 -77
- package/components/preview-card.rip +26 -18
- package/components/select.rip +33 -22
- package/components/tooltip.rip +37 -52
- package/package.json +12 -2
package/README.md
CHANGED
|
@@ -28,6 +28,9 @@ cd packages/ui
|
|
|
28
28
|
rip server
|
|
29
29
|
```
|
|
30
30
|
|
|
31
|
+
For browser quality checks and overlay regression tests, see
|
|
32
|
+
[TESTING.md](TESTING.md).
|
|
33
|
+
|
|
31
34
|
Every widget:
|
|
32
35
|
- Handles all keyboard interactions per WAI-ARIA Authoring Practices
|
|
33
36
|
- Sets correct ARIA attributes automatically
|
|
@@ -17,25 +17,30 @@ export AlertDialog = component
|
|
|
17
17
|
@initialFocus := null
|
|
18
18
|
|
|
19
19
|
_prevFocus = null
|
|
20
|
-
_cleanupTrap = null
|
|
21
20
|
_id =! "adlg-#{Math.random().toString(36).slice(2, 8)}"
|
|
22
21
|
|
|
22
|
+
~>
|
|
23
|
+
ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>
|
|
24
|
+
if not isOpen and @open
|
|
25
|
+
@open = false
|
|
26
|
+
@emit 'close'
|
|
27
|
+
), false
|
|
28
|
+
|
|
23
29
|
~>
|
|
24
30
|
if @open
|
|
25
31
|
_prevFocus = document.activeElement
|
|
26
32
|
ARIA.lockScroll(this)
|
|
27
33
|
requestAnimationFrame =>
|
|
28
|
-
panel = @
|
|
34
|
+
panel = @_dialog
|
|
29
35
|
if panel
|
|
30
36
|
ARIA.wireAria panel, _id
|
|
37
|
+
panel.setAttribute 'role', 'alertdialog'
|
|
31
38
|
if @initialFocus
|
|
32
39
|
target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
|
|
33
40
|
target?.focus()
|
|
34
41
|
else
|
|
35
42
|
panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
|
|
36
|
-
_cleanupTrap = ARIA.trapFocus(panel)
|
|
37
43
|
return ->
|
|
38
|
-
_cleanupTrap?()
|
|
39
44
|
ARIA.unlockScroll(this)
|
|
40
45
|
_prevFocus?.focus()
|
|
41
46
|
|
|
@@ -44,7 +49,5 @@ export AlertDialog = component
|
|
|
44
49
|
@emit 'close'
|
|
45
50
|
|
|
46
51
|
render
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
div ref: "_panel", role: "alertdialog", aria-modal: "true", tabindex: "-1"
|
|
50
|
-
slot
|
|
52
|
+
dialog ref: "_dialog", $open: @open?!
|
|
53
|
+
slot
|
package/components/dialog.rip
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Dialog — accessible headless modal dialog
|
|
2
2
|
#
|
|
3
|
-
#
|
|
3
|
+
# Native `<dialog>` variant that uses `showModal()` for top-layer modality.
|
|
4
4
|
# Restores focus to the previously focused element on close.
|
|
5
5
|
# Auto-wires aria-labelledby (first h1-h6) and aria-describedby (first p).
|
|
6
6
|
#
|
|
@@ -18,15 +18,21 @@ export Dialog = component
|
|
|
18
18
|
@initialFocus := null
|
|
19
19
|
|
|
20
20
|
_prevFocus = null
|
|
21
|
-
_cleanupTrap = null
|
|
22
21
|
_id =! "dlg-#{Math.random().toString(36).slice(2, 8)}"
|
|
23
22
|
|
|
23
|
+
~>
|
|
24
|
+
ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>
|
|
25
|
+
if not isOpen and @open
|
|
26
|
+
@open = false
|
|
27
|
+
@emit 'close'
|
|
28
|
+
), @dismissable
|
|
29
|
+
|
|
24
30
|
~>
|
|
25
31
|
if @open
|
|
26
32
|
_prevFocus = document.activeElement
|
|
27
33
|
ARIA.lockScroll(this)
|
|
28
34
|
requestAnimationFrame =>
|
|
29
|
-
panel = @
|
|
35
|
+
panel = @_dialog
|
|
30
36
|
if panel
|
|
31
37
|
ARIA.wireAria panel, _id
|
|
32
38
|
if @initialFocus
|
|
@@ -34,9 +40,7 @@ export Dialog = component
|
|
|
34
40
|
target?.focus()
|
|
35
41
|
else
|
|
36
42
|
panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
|
|
37
|
-
_cleanupTrap = ARIA.trapFocus(panel)
|
|
38
43
|
return ->
|
|
39
|
-
_cleanupTrap?()
|
|
40
44
|
ARIA.unlockScroll(this)
|
|
41
45
|
_prevFocus?.focus()
|
|
42
46
|
|
|
@@ -45,17 +49,15 @@ export Dialog = component
|
|
|
45
49
|
@emit 'close'
|
|
46
50
|
|
|
47
51
|
onKeydown: (e) ->
|
|
48
|
-
if e.key is 'Escape'
|
|
52
|
+
if e.key is 'Escape'
|
|
49
53
|
e.preventDefault()
|
|
50
54
|
@close() if @dismissable
|
|
51
55
|
|
|
52
56
|
onBackdropClick: (e) ->
|
|
53
|
-
|
|
57
|
+
if e.target is e.currentTarget and @dismissable
|
|
58
|
+
@_dialog?.close()
|
|
54
59
|
|
|
55
60
|
render
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
@keydown: @onKeydown
|
|
60
|
-
div ref: "_panel", role: "dialog", aria-modal: "true", tabindex: "-1"
|
|
61
|
-
slot
|
|
61
|
+
dialog ref: "_dialog", @click: @onBackdropClick, @keydown: @onKeydown
|
|
62
|
+
$open: @open?!
|
|
63
|
+
slot
|
package/components/drawer.rip
CHANGED
|
@@ -15,22 +15,26 @@ export Drawer = component
|
|
|
15
15
|
@dismissable := true
|
|
16
16
|
|
|
17
17
|
_prevFocus = null
|
|
18
|
-
_scrollY = 0
|
|
19
18
|
_id =! "drw-#{Math.random().toString(36).slice(2, 8)}"
|
|
20
19
|
|
|
20
|
+
~>
|
|
21
|
+
ARIA.bindDialog @open, (=> @_dialog), ((isOpen) =>
|
|
22
|
+
if not isOpen and @open
|
|
23
|
+
@open = false
|
|
24
|
+
@emit 'close'
|
|
25
|
+
), @dismissable
|
|
26
|
+
|
|
21
27
|
~>
|
|
22
28
|
if @open
|
|
23
29
|
_prevFocus = document.activeElement
|
|
24
|
-
|
|
30
|
+
ARIA.lockScroll(this)
|
|
25
31
|
requestAnimationFrame =>
|
|
26
|
-
panel = @
|
|
32
|
+
panel = @_dialog
|
|
27
33
|
if panel
|
|
28
34
|
ARIA.wireAria panel, _id
|
|
29
35
|
panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')?[0]?.focus()
|
|
30
|
-
@_cleanupTrap = ARIA.trapFocus(panel)
|
|
31
36
|
return ->
|
|
32
|
-
|
|
33
|
-
document.body.style.overflow = ''
|
|
37
|
+
ARIA.unlockScroll(this)
|
|
34
38
|
_prevFocus?.focus()
|
|
35
39
|
|
|
36
40
|
close: ->
|
|
@@ -43,13 +47,12 @@ export Drawer = component
|
|
|
43
47
|
@close()
|
|
44
48
|
|
|
45
49
|
onBackdropClick: (e) ->
|
|
46
|
-
|
|
50
|
+
if e.target is e.currentTarget and @dismissable
|
|
51
|
+
@_dialog?.close()
|
|
47
52
|
|
|
48
53
|
render
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
$side: @side
|
|
55
|
-
slot
|
|
54
|
+
dialog ref: "_dialog", $open: @open?!, $side: @side
|
|
55
|
+
@click: @onBackdropClick
|
|
56
|
+
@keydown: @onKeydown
|
|
57
|
+
$side: @side
|
|
58
|
+
slot
|
package/components/menu.rip
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
#
|
|
3
3
|
# Keyboard: ArrowDown/Up to navigate, Enter/Space to select, Escape to close,
|
|
4
4
|
# Home/End for first/last. Exposes $open on menu, $highlighted on items.
|
|
5
|
-
# Ships zero CSS.
|
|
5
|
+
# Uses native `popover="auto"` + anchor positioning. Ships zero CSS.
|
|
6
6
|
#
|
|
7
7
|
# Usage:
|
|
8
8
|
# Menu
|
|
@@ -18,6 +18,7 @@ export Menu = component
|
|
|
18
18
|
highlightedIndex := -1
|
|
19
19
|
typeaheadBuffer := ''
|
|
20
20
|
typeaheadTimer := null
|
|
21
|
+
_id =! "menu-#{Math.random().toString(36).slice(2, 8)}"
|
|
21
22
|
|
|
22
23
|
items ~=
|
|
23
24
|
return [] unless @_slot
|
|
@@ -35,9 +36,7 @@ export Menu = component
|
|
|
35
36
|
openMenu: ->
|
|
36
37
|
open = true
|
|
37
38
|
highlightedIndex = 0
|
|
38
|
-
requestAnimationFrame =>
|
|
39
|
-
@_position()
|
|
40
|
-
@_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
|
|
39
|
+
requestAnimationFrame => @_list?.querySelectorAll('[role="menuitem"]')[0]?.focus()
|
|
41
40
|
|
|
42
41
|
close: ->
|
|
43
42
|
open = false
|
|
@@ -73,7 +72,15 @@ export Menu = component
|
|
|
73
72
|
highlightedIndex = idx
|
|
74
73
|
@_list?.querySelectorAll('[role="menuitem"]')[idx]?.focus()
|
|
75
74
|
|
|
76
|
-
|
|
75
|
+
_applyPlacement: ->
|
|
76
|
+
return unless @_list
|
|
77
|
+
@_list.style.position = 'fixed'
|
|
78
|
+
@_list.style.inset = 'auto'
|
|
79
|
+
@_list.style.margin = '0'
|
|
80
|
+
@_list.style.positionArea = 'bottom start'
|
|
81
|
+
@_list.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
82
|
+
@_list.style.positionVisibility = 'anchors-visible'
|
|
83
|
+
@_list.style.marginTop = '4px'
|
|
77
84
|
|
|
78
85
|
onTriggerKeydown: (e) ->
|
|
79
86
|
return if @disabled
|
|
@@ -98,7 +105,14 @@ export Menu = component
|
|
|
98
105
|
tab: => @close()
|
|
99
106
|
char: => @_typeahead(e.key)
|
|
100
107
|
|
|
101
|
-
~>
|
|
108
|
+
~>
|
|
109
|
+
if @_list
|
|
110
|
+
@_list.id = _id
|
|
111
|
+
@_list.setAttribute 'popover', 'auto'
|
|
112
|
+
@_applyPlacement()
|
|
113
|
+
if @_trigger
|
|
114
|
+
@_trigger.setAttribute 'aria-controls', _id
|
|
115
|
+
ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_trigger)
|
|
102
116
|
|
|
103
117
|
render
|
|
104
118
|
.
|
|
@@ -115,14 +129,13 @@ export Menu = component
|
|
|
115
129
|
. ref: "_slot", style: "display:none"
|
|
116
130
|
slot
|
|
117
131
|
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
= item.textContent
|
|
132
|
+
div ref: "_list", role: "menu", $open: open?!, style: "position:fixed;margin:0;inset:auto", @keydown: @onMenuKeydown
|
|
133
|
+
for item, idx in items
|
|
134
|
+
div role: item.getAttribute('role') or 'menuitem'
|
|
135
|
+
tabindex: "-1"
|
|
136
|
+
aria-checked: item.getAttribute('aria-checked')?!
|
|
137
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
138
|
+
$disabled: item.dataset.disabled?!
|
|
139
|
+
@click: (=> @selectIndex(idx))
|
|
140
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
141
|
+
= item.textContent
|
package/components/popover.rip
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# Popover — accessible headless popover with anchor positioning
|
|
2
2
|
#
|
|
3
|
-
#
|
|
4
|
-
# Exposes $open, $placement on content. Ships zero CSS.
|
|
3
|
+
# Uses the native Popover API (top-layer + light-dismiss) and CSS anchor
|
|
4
|
+
# positioning. Exposes $open, $placement on content. Ships zero CSS.
|
|
5
5
|
#
|
|
6
6
|
# Usage:
|
|
7
7
|
# Popover placement: "bottom-start"
|
|
@@ -23,83 +23,64 @@ export Popover = component
|
|
|
23
23
|
_hoverCloseTimer := null
|
|
24
24
|
_id =! "pop-#{Math.random().toString(36).slice(2, 8)}"
|
|
25
25
|
|
|
26
|
+
_applyPlacement: ->
|
|
27
|
+
floating = @_content?.querySelector('[data-content]')
|
|
28
|
+
return unless floating
|
|
29
|
+
[side, align] = @placement.split('-')
|
|
30
|
+
align ?= 'center'
|
|
31
|
+
area = "#{side} #{align}"
|
|
32
|
+
floating.style.position = 'fixed'
|
|
33
|
+
floating.style.inset = 'auto'
|
|
34
|
+
floating.style.margin = '0'
|
|
35
|
+
floating.style.positionArea = area
|
|
36
|
+
floating.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
37
|
+
floating.style.positionVisibility = 'anchors-visible'
|
|
38
|
+
floating.style.marginTop = ''
|
|
39
|
+
floating.style.marginRight = ''
|
|
40
|
+
floating.style.marginBottom = ''
|
|
41
|
+
floating.style.marginLeft = ''
|
|
42
|
+
switch side
|
|
43
|
+
when 'bottom' then floating.style.marginTop = "#{@offset}px"
|
|
44
|
+
when 'top' then floating.style.marginBottom = "#{@offset}px"
|
|
45
|
+
when 'left' then floating.style.marginRight = "#{@offset}px"
|
|
46
|
+
when 'right' then floating.style.marginLeft = "#{@offset}px"
|
|
47
|
+
|
|
26
48
|
mounted: ->
|
|
27
49
|
_ready = true
|
|
28
50
|
trigger = @_content?.querySelector('[data-trigger]')
|
|
29
|
-
|
|
51
|
+
floating = @_content?.querySelector('[data-content]')
|
|
52
|
+
if trigger and floating
|
|
53
|
+
floating.id = _id
|
|
54
|
+
floating.setAttribute 'popover', 'auto'
|
|
30
55
|
trigger.setAttribute 'aria-expanded', false
|
|
31
56
|
trigger.setAttribute 'aria-haspopup', 'dialog'
|
|
32
|
-
trigger.
|
|
57
|
+
trigger.setAttribute 'aria-controls', _id
|
|
58
|
+
trigger.addEventListener 'click', =>
|
|
59
|
+
return if @disabled
|
|
60
|
+
open = not open
|
|
33
61
|
trigger.addEventListener 'keydown', (e) =>
|
|
34
62
|
if e.key in ['Enter', ' ', 'ArrowDown']
|
|
35
63
|
e.preventDefault()
|
|
36
|
-
@
|
|
64
|
+
return if @disabled
|
|
65
|
+
open = not open
|
|
37
66
|
if @openOnHover
|
|
38
67
|
trigger.addEventListener 'mouseenter', =>
|
|
39
68
|
clearTimeout _hoverCloseTimer if _hoverCloseTimer
|
|
40
|
-
_hoverTimer = setTimeout (=>
|
|
69
|
+
_hoverTimer = setTimeout (=> open = true), @hoverDelay
|
|
41
70
|
trigger.addEventListener 'mouseleave', =>
|
|
42
71
|
clearTimeout _hoverTimer if _hoverTimer
|
|
43
|
-
_hoverCloseTimer = setTimeout (=>
|
|
72
|
+
_hoverCloseTimer = setTimeout (=> open = false), @hoverCloseDelay
|
|
73
|
+
@_applyPlacement()
|
|
44
74
|
|
|
45
75
|
toggle: ->
|
|
46
76
|
return if @disabled
|
|
47
|
-
|
|
77
|
+
open = not open
|
|
48
78
|
|
|
49
79
|
openPopover: ->
|
|
50
80
|
open = true
|
|
51
|
-
requestAnimationFrame => @_position()
|
|
52
81
|
|
|
53
82
|
close: ->
|
|
54
83
|
open = false
|
|
55
|
-
@_content?.querySelector('[data-trigger]')?.focus()
|
|
56
|
-
|
|
57
|
-
_position: ->
|
|
58
|
-
trigger = @_content?.querySelector('[data-trigger]')
|
|
59
|
-
floating = @_content?.querySelector('[data-content]')
|
|
60
|
-
return unless trigger and floating
|
|
61
|
-
@_content.style.position = 'relative'
|
|
62
|
-
tr = trigger.getBoundingClientRect()
|
|
63
|
-
cr = @_content.getBoundingClientRect()
|
|
64
|
-
fl = floating.getBoundingClientRect()
|
|
65
|
-
[side, align] = @placement.split('-')
|
|
66
|
-
align ?= 'center'
|
|
67
|
-
gap = @offset
|
|
68
|
-
|
|
69
|
-
x = switch side
|
|
70
|
-
when 'bottom', 'top'
|
|
71
|
-
switch align
|
|
72
|
-
when 'start' then tr.left - cr.left
|
|
73
|
-
when 'end' then tr.right - cr.left - fl.width
|
|
74
|
-
else tr.left - cr.left + (tr.width - fl.width) / 2
|
|
75
|
-
when 'right' then tr.right - cr.left + gap
|
|
76
|
-
when 'left' then tr.left - cr.left - fl.width - gap
|
|
77
|
-
|
|
78
|
-
y = switch side
|
|
79
|
-
when 'bottom' then tr.bottom - cr.top + gap
|
|
80
|
-
when 'top' then tr.top - cr.top - fl.height - gap
|
|
81
|
-
when 'left', 'right'
|
|
82
|
-
switch align
|
|
83
|
-
when 'start' then tr.top - cr.top
|
|
84
|
-
when 'end' then tr.bottom - cr.top - fl.height
|
|
85
|
-
else tr.top - cr.top + (tr.height - fl.height) / 2
|
|
86
|
-
|
|
87
|
-
if side is 'bottom' and tr.bottom + gap + fl.height > window.innerHeight
|
|
88
|
-
y = tr.top - cr.top - fl.height - gap
|
|
89
|
-
if side is 'top' and tr.top - fl.height - gap < 0
|
|
90
|
-
y = tr.bottom - cr.top + gap
|
|
91
|
-
if side is 'right' and tr.right + gap + fl.width > window.innerWidth
|
|
92
|
-
x = tr.left - cr.left - fl.width - gap
|
|
93
|
-
if side is 'left' and tr.left - fl.width - gap < 0
|
|
94
|
-
x = tr.right - cr.left + gap
|
|
95
|
-
|
|
96
|
-
x = Math.max(4 - cr.left, Math.min(x, window.innerWidth - cr.left - fl.width - 4))
|
|
97
|
-
|
|
98
|
-
floating.style.position = 'absolute'
|
|
99
|
-
floating.style.left = "#{x}px"
|
|
100
|
-
floating.style.top = "#{y}px"
|
|
101
|
-
floating.style.zIndex = '50'
|
|
102
|
-
floating.style.visibility = 'visible'
|
|
103
84
|
|
|
104
85
|
~>
|
|
105
86
|
return unless _ready
|
|
@@ -108,29 +89,14 @@ export Popover = component
|
|
|
108
89
|
if trigger
|
|
109
90
|
trigger.setAttribute 'aria-expanded', !!open
|
|
110
91
|
if floating
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
floating.hidden = false
|
|
116
|
-
floating.style.visibility = 'hidden'
|
|
117
|
-
floating.setAttribute 'data-open', ''
|
|
118
|
-
floating.setAttribute 'data-placement', @placement
|
|
119
|
-
ARIA.wireAria floating, _id
|
|
120
|
-
else
|
|
121
|
-
floating.hidden = true
|
|
122
|
-
floating.removeAttribute 'data-open'
|
|
92
|
+
floating.setAttribute 'data-placement', @placement
|
|
93
|
+
if open then floating.setAttribute 'data-open', '' else floating.removeAttribute 'data-open'
|
|
94
|
+
ARIA.wireAria floating, _id
|
|
95
|
+
@_applyPlacement()
|
|
123
96
|
|
|
124
97
|
~>
|
|
125
98
|
return unless _ready
|
|
126
|
-
|
|
127
|
-
onDown = (e) =>
|
|
128
|
-
trigger = @_content?.querySelector('[data-trigger]')
|
|
129
|
-
floating = @_content?.querySelector('[data-content]')
|
|
130
|
-
unless trigger?.contains(e.target) or floating?.contains(e.target)
|
|
131
|
-
@close()
|
|
132
|
-
document.addEventListener 'mousedown', onDown
|
|
133
|
-
return -> document.removeEventListener 'mousedown', onDown
|
|
99
|
+
ARIA.bindPopover open, (=> @_content?.querySelector('[data-content]')), ((isOpen) => open = isOpen), (=> @_content?.querySelector('[data-trigger]'))
|
|
134
100
|
|
|
135
101
|
onKeydown: (e) ->
|
|
136
102
|
if e.key is 'Escape' and open
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# PreviewCard — accessible headless hover preview card
|
|
2
2
|
#
|
|
3
3
|
# Shows a floating card on hover/focus of a trigger element. Dismisses
|
|
4
|
-
# on mouse leave or blur.
|
|
4
|
+
# on mouse leave or blur. Uses native `popover="hint"` for top-layer behavior.
|
|
5
|
+
# Ships zero CSS.
|
|
5
6
|
#
|
|
6
7
|
# Usage:
|
|
7
8
|
# PreviewCard delay: 400
|
|
@@ -17,6 +18,7 @@ export PreviewCard = component
|
|
|
17
18
|
_ready := false
|
|
18
19
|
_openTimer := null
|
|
19
20
|
_closeTimer := null
|
|
21
|
+
_id =! "pc-#{Math.random().toString(36).slice(2, 8)}"
|
|
20
22
|
|
|
21
23
|
beforeUnmount: ->
|
|
22
24
|
clearTimeout _openTimer if _openTimer
|
|
@@ -25,13 +27,19 @@ export PreviewCard = component
|
|
|
25
27
|
mounted: ->
|
|
26
28
|
_ready = true
|
|
27
29
|
trigger = @_root?.querySelector('[data-trigger]')
|
|
28
|
-
|
|
30
|
+
floating = @_root?.querySelector('[data-content]')
|
|
31
|
+
return unless trigger and floating
|
|
32
|
+
floating.id = _id
|
|
33
|
+
floating.setAttribute 'popover', 'hint'
|
|
34
|
+
trigger.setAttribute 'aria-controls', _id
|
|
35
|
+
trigger.setAttribute 'aria-expanded', false
|
|
29
36
|
trigger.addEventListener 'mouseenter', =>
|
|
30
37
|
clearTimeout _closeTimer if _closeTimer
|
|
31
|
-
_openTimer = setTimeout (=> open = true
|
|
38
|
+
_openTimer = setTimeout (=> open = true), @delay
|
|
32
39
|
trigger.addEventListener 'mouseleave', =>
|
|
33
40
|
clearTimeout _openTimer if _openTimer
|
|
34
41
|
_closeTimer = setTimeout (=> open = false), @closeDelay
|
|
42
|
+
@_applyPlacement()
|
|
35
43
|
trigger.addEventListener 'focus', =>
|
|
36
44
|
clearTimeout _closeTimer if _closeTimer
|
|
37
45
|
_openTimer = setTimeout (=> open = true; @_position()), @delay
|
|
@@ -39,25 +47,27 @@ export PreviewCard = component
|
|
|
39
47
|
clearTimeout _openTimer if _openTimer
|
|
40
48
|
_closeTimer = setTimeout (=> open = false), @closeDelay
|
|
41
49
|
|
|
42
|
-
|
|
43
|
-
trigger = @_root?.querySelector('[data-trigger]')
|
|
50
|
+
_applyPlacement: ->
|
|
44
51
|
floating = @_root?.querySelector('[data-content]')
|
|
45
|
-
return unless
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
floating.style.
|
|
50
|
-
floating.style.
|
|
51
|
-
floating.style.
|
|
52
|
-
floating.style.
|
|
52
|
+
return unless floating
|
|
53
|
+
floating.style.position = 'fixed'
|
|
54
|
+
floating.style.inset = 'auto'
|
|
55
|
+
floating.style.margin = '0'
|
|
56
|
+
floating.style.positionArea = 'bottom start'
|
|
57
|
+
floating.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
58
|
+
floating.style.positionVisibility = 'anchors-visible'
|
|
59
|
+
floating.style.marginTop = '4px'
|
|
53
60
|
|
|
54
61
|
~>
|
|
55
62
|
return unless _ready
|
|
63
|
+
trigger = @_root?.querySelector('[data-trigger]')
|
|
56
64
|
floating = @_root?.querySelector('[data-content]')
|
|
57
|
-
return unless floating
|
|
58
|
-
|
|
65
|
+
return unless floating and trigger
|
|
66
|
+
trigger.setAttribute 'aria-expanded', !!open
|
|
67
|
+
@_applyPlacement()
|
|
68
|
+
if open then floating.setAttribute('data-open', '') else floating.removeAttribute('data-open')
|
|
69
|
+
ARIA.bindPopover open, (=> floating), ((isOpen) => open = isOpen), (=> trigger)
|
|
59
70
|
if open
|
|
60
|
-
floating.setAttribute 'data-open', ''
|
|
61
71
|
onEnter = => clearTimeout _closeTimer if _closeTimer
|
|
62
72
|
onLeave = => _closeTimer = setTimeout (=> open = false), @closeDelay
|
|
63
73
|
floating.addEventListener 'mouseenter', onEnter
|
|
@@ -65,8 +75,6 @@ export PreviewCard = component
|
|
|
65
75
|
return ->
|
|
66
76
|
floating.removeEventListener 'mouseenter', onEnter
|
|
67
77
|
floating.removeEventListener 'mouseleave', onLeave
|
|
68
|
-
else
|
|
69
|
-
floating.removeAttribute 'data-open'
|
|
70
78
|
|
|
71
79
|
render
|
|
72
80
|
div ref: "_root"
|
package/components/select.rip
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
# Home/End for first/last, typeahead to jump by character.
|
|
5
5
|
#
|
|
6
6
|
# Exposes $open and $placeholder on button, $highlighted and $selected on options.
|
|
7
|
+
# Uses native `popover="auto"` + anchor positioning.
|
|
7
8
|
# Ships zero CSS — style entirely via attribute selectors in your stylesheet.
|
|
8
9
|
#
|
|
9
10
|
# Usage:
|
|
@@ -42,9 +43,7 @@ export Select = component
|
|
|
42
43
|
openMenu: ->
|
|
43
44
|
open = true
|
|
44
45
|
highlightedIndex = Math.max(0, options.findIndex (o) -> @getOpt(o) is String(@value))
|
|
45
|
-
requestAnimationFrame =>
|
|
46
|
-
@_position()
|
|
47
|
-
@_focusHighlighted()
|
|
46
|
+
requestAnimationFrame => @_focusHighlighted()
|
|
48
47
|
|
|
49
48
|
close: ->
|
|
50
49
|
open = false
|
|
@@ -105,9 +104,22 @@ export Select = component
|
|
|
105
104
|
el?.focus()
|
|
106
105
|
el?.scrollIntoView { block: 'nearest' }
|
|
107
106
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
107
|
+
_applyPlacement: ->
|
|
108
|
+
return unless @_list
|
|
109
|
+
@_list.style.position = 'fixed'
|
|
110
|
+
@_list.style.inset = 'auto'
|
|
111
|
+
@_list.style.margin = '0'
|
|
112
|
+
@_list.style.positionArea = 'bottom start'
|
|
113
|
+
@_list.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
114
|
+
@_list.style.positionVisibility = 'anchors-visible'
|
|
115
|
+
@_list.style.marginTop = '4px'
|
|
116
|
+
@_list.style.minWidth = 'anchor-size(width)'
|
|
117
|
+
|
|
118
|
+
~>
|
|
119
|
+
if @_list
|
|
120
|
+
@_list.setAttribute 'popover', 'auto'
|
|
121
|
+
@_applyPlacement()
|
|
122
|
+
ARIA.bindPopover open, (=> @_list), ((isOpen) => open = isOpen), (=> @_trigger)
|
|
111
123
|
|
|
112
124
|
render
|
|
113
125
|
.
|
|
@@ -130,19 +142,18 @@ export Select = component
|
|
|
130
142
|
slot
|
|
131
143
|
|
|
132
144
|
# Dropdown listbox
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
= opt.textContent
|
|
145
|
+
div ref: "_list", id: _listId, role: "listbox", style: "position:fixed;margin:0;inset:auto"
|
|
146
|
+
$open: open?!
|
|
147
|
+
@keydown: @onListKeydown
|
|
148
|
+
for opt, idx in options
|
|
149
|
+
div role: "option"
|
|
150
|
+
tabindex: "-1"
|
|
151
|
+
$value: @getOpt(opt)
|
|
152
|
+
$highlighted: (idx is highlightedIndex)?!
|
|
153
|
+
$selected: (@getOpt(opt) is String(@value))?!
|
|
154
|
+
$disabled: @isDisabled(opt)?!
|
|
155
|
+
aria-selected: @getOpt(opt) is String(@value)
|
|
156
|
+
aria-disabled: @isDisabled(opt)?!
|
|
157
|
+
@click: (=> @selectIndex(idx))
|
|
158
|
+
@mouseenter: (=> highlightedIndex = idx)
|
|
159
|
+
= opt.textContent
|
package/components/tooltip.rip
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# Tooltip — accessible headless tooltip with delay and positioning
|
|
2
2
|
#
|
|
3
|
-
# Shows on hover/focus with configurable delay. Uses aria-describedby
|
|
3
|
+
# Shows on hover/focus with configurable delay. Uses aria-describedby and
|
|
4
|
+
# native `popover="hint"` for top-layer behavior.
|
|
4
5
|
# Exposes $open, $entering, $exiting. Ships zero CSS.
|
|
5
6
|
#
|
|
6
7
|
# Usage:
|
|
@@ -24,6 +25,26 @@ export Tooltip = component
|
|
|
24
25
|
_hideTimer := null
|
|
25
26
|
_id =! "tip-#{Math.random().toString(36).slice(2, 8)}"
|
|
26
27
|
|
|
28
|
+
_applyPlacement: ->
|
|
29
|
+
return unless @_tip
|
|
30
|
+
[side, align] = @placement.split('-')
|
|
31
|
+
align ?= 'center'
|
|
32
|
+
@_tip.style.position = 'fixed'
|
|
33
|
+
@_tip.style.inset = 'auto'
|
|
34
|
+
@_tip.style.margin = '0'
|
|
35
|
+
@_tip.style.positionArea = "#{side} #{align}"
|
|
36
|
+
@_tip.style.positionTry = 'flip-block, flip-inline, flip-block flip-inline'
|
|
37
|
+
@_tip.style.positionVisibility = 'anchors-visible'
|
|
38
|
+
@_tip.style.marginTop = ''
|
|
39
|
+
@_tip.style.marginRight = ''
|
|
40
|
+
@_tip.style.marginBottom = ''
|
|
41
|
+
@_tip.style.marginLeft = ''
|
|
42
|
+
switch side
|
|
43
|
+
when 'bottom' then @_tip.style.marginTop = "#{@offset}px"
|
|
44
|
+
when 'top' then @_tip.style.marginBottom = "#{@offset}px"
|
|
45
|
+
when 'left' then @_tip.style.marginRight = "#{@offset}px"
|
|
46
|
+
when 'right' then @_tip.style.marginLeft = "#{@offset}px"
|
|
47
|
+
|
|
27
48
|
show: ->
|
|
28
49
|
clearTimeout _hideTimer if _hideTimer
|
|
29
50
|
delay = if (Date.now() - lastCloseTime) < GROUP_TIMEOUT then 0 else @delay
|
|
@@ -32,7 +53,6 @@ export Tooltip = component
|
|
|
32
53
|
entering = true
|
|
33
54
|
setTimeout =>
|
|
34
55
|
entering = false
|
|
35
|
-
@_position()
|
|
36
56
|
, 0
|
|
37
57
|
, delay
|
|
38
58
|
|
|
@@ -49,51 +69,17 @@ export Tooltip = component
|
|
|
49
69
|
clearTimeout _hideTimer if _hideTimer
|
|
50
70
|
exiting = false
|
|
51
71
|
|
|
52
|
-
_position: ->
|
|
53
|
-
return unless @_trigger and @_tip
|
|
54
|
-
tr = @_trigger.getBoundingClientRect()
|
|
55
|
-
fl = @_tip.getBoundingClientRect()
|
|
56
|
-
[side, align] = @placement.split('-')
|
|
57
|
-
align ?= 'center'
|
|
58
|
-
gap = @offset
|
|
59
|
-
|
|
60
|
-
x = switch side
|
|
61
|
-
when 'bottom', 'top'
|
|
62
|
-
switch align
|
|
63
|
-
when 'start' then tr.left
|
|
64
|
-
when 'end' then tr.right - fl.width
|
|
65
|
-
else tr.left + (tr.width - fl.width) / 2
|
|
66
|
-
when 'right' then tr.right + gap
|
|
67
|
-
when 'left' then tr.left - fl.width - gap
|
|
68
|
-
|
|
69
|
-
y = switch side
|
|
70
|
-
when 'bottom' then tr.bottom + gap
|
|
71
|
-
when 'top' then tr.top - fl.height - gap
|
|
72
|
-
when 'left', 'right'
|
|
73
|
-
switch align
|
|
74
|
-
when 'start' then tr.top
|
|
75
|
-
when 'end' then tr.bottom - fl.height
|
|
76
|
-
else tr.top + (tr.height - fl.height) / 2
|
|
77
|
-
|
|
78
|
-
if side is 'bottom' and y + fl.height > window.innerHeight
|
|
79
|
-
y = tr.top - fl.height - gap
|
|
80
|
-
if side is 'top' and y < 0
|
|
81
|
-
y = tr.bottom + gap
|
|
82
|
-
if side is 'right' and x + fl.width > window.innerWidth
|
|
83
|
-
x = tr.left - fl.width - gap
|
|
84
|
-
if side is 'left' and x < 0
|
|
85
|
-
x = tr.right + gap
|
|
86
|
-
|
|
87
|
-
x = Math.max(4, Math.min(x, window.innerWidth - fl.width - 4))
|
|
88
|
-
|
|
89
|
-
@_tip.style.position = 'fixed'
|
|
90
|
-
@_tip.style.left = "#{x}px"
|
|
91
|
-
@_tip.style.top = "#{y}px"
|
|
92
|
-
|
|
93
72
|
beforeUnmount: ->
|
|
94
73
|
clearTimeout _showTimer if _showTimer
|
|
95
74
|
clearTimeout _hideTimer if _hideTimer
|
|
96
75
|
|
|
76
|
+
~>
|
|
77
|
+
if @_tip
|
|
78
|
+
@_tip.setAttribute 'popover', 'hint'
|
|
79
|
+
@_applyPlacement()
|
|
80
|
+
if open then @_tip.setAttribute('data-open', '') else @_tip.removeAttribute('data-open')
|
|
81
|
+
ARIA.bindPopover open, (=> @_tip), ((isOpen) => open = isOpen), (=> @_trigger)
|
|
82
|
+
|
|
97
83
|
render
|
|
98
84
|
.
|
|
99
85
|
div ref: "_trigger"
|
|
@@ -104,12 +90,11 @@ export Tooltip = component
|
|
|
104
90
|
@focusout: @hide
|
|
105
91
|
slot
|
|
106
92
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
@text
|
|
93
|
+
div ref: "_tip", id: _id, role: "tooltip", style: "position:fixed;margin:0;inset:auto"
|
|
94
|
+
$open: open?!
|
|
95
|
+
$entering: entering?!
|
|
96
|
+
$exiting: exiting?!
|
|
97
|
+
$placement: @placement
|
|
98
|
+
@mouseenter: (=> @_cancelHide() if @hoverable)
|
|
99
|
+
@mouseleave: (=> @hide() if @hoverable)
|
|
100
|
+
@text
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rip-lang/ui",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.56",
|
|
4
4
|
"author": "Steve Shreeve <steve.shreeve@gmail.com>",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -34,7 +34,17 @@
|
|
|
34
34
|
],
|
|
35
35
|
"license": "MIT",
|
|
36
36
|
"type": "module",
|
|
37
|
+
"scripts": {
|
|
38
|
+
"test:e2e": "bunx playwright test -c playwright.config.mjs",
|
|
39
|
+
"test:e2e:chromium": "bunx playwright test -c playwright.config.mjs --project=chromium",
|
|
40
|
+
"test:e2e:headed": "bunx playwright test -c playwright.config.mjs --headed --project=chromium",
|
|
41
|
+
"test:e2e:axe": "UI_AXE=1 bunx playwright test -c playwright.config.mjs --project=chromium"
|
|
42
|
+
},
|
|
37
43
|
"dependencies": {
|
|
38
|
-
"rip-lang": ">=3.13.
|
|
44
|
+
"rip-lang": ">=3.13.109"
|
|
45
|
+
},
|
|
46
|
+
"devDependencies": {
|
|
47
|
+
"@axe-core/playwright": "4.11.1",
|
|
48
|
+
"playwright": "1.58.2"
|
|
39
49
|
}
|
|
40
50
|
}
|