@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 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 = @_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
- if @open
48
- div ref: "_backdrop", $open: true
49
- div ref: "_panel", role: "alertdialog", aria-modal: "true", tabindex: "-1"
50
- slot
52
+ dialog ref: "_dialog", $open: @open?!
53
+ slot
@@ -1,6 +1,6 @@
1
1
  # Dialog — accessible headless modal dialog
2
2
  #
3
- # Traps focus, locks scroll, dismisses on Escape or click outside.
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 = @_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' and dialogStack[dialogStack.length - 1] is this
52
+ if e.key is 'Escape'
49
53
  e.preventDefault()
50
54
  @close() if @dismissable
51
55
 
52
56
  onBackdropClick: (e) ->
53
- @close() if e.target is e.currentTarget and @dismissable
57
+ if e.target is e.currentTarget and @dismissable
58
+ @_dialog?.close()
54
59
 
55
60
  render
56
- if @open
57
- div ref: "_backdrop", $open: true,
58
- @click: @onBackdropClick,
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
@@ -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
- document.body.style.overflow = 'hidden'
30
+ ARIA.lockScroll(this)
25
31
  requestAnimationFrame =>
26
- panel = @_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
- @_cleanupTrap?()
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
- @close() if e.target is e.currentTarget and @dismissable
50
+ if e.target is e.currentTarget and @dismissable
51
+ @_dialog?.close()
47
52
 
48
53
  render
49
- if @open
50
- div ref: "_backdrop", $open: true, $side: @side
51
- @click: @onBackdropClick
52
- @keydown: @onKeydown
53
- div ref: "_panel", role: "dialog", aria-modal: "true", tabindex: "-1"
54
- $side: @side
55
- slot
54
+ dialog ref: "_dialog", $open: @open?!, $side: @side
55
+ @click: @onBackdropClick
56
+ @keydown: @onKeydown
57
+ $side: @side
58
+ slot
@@ -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
- _position: -> ARIA.positionBelow @_trigger, @_list
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
- ~> ARIA.popupDismiss open, (=> @_list), (=> @close()), [=> @_trigger], (=> @_position())
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
- if open
119
- div ref: "_list", role: "menu", $open: true, style: "position:fixed;visibility:hidden", @keydown: @onMenuKeydown
120
- for item, idx in items
121
- div role: item.getAttribute('role') or 'menuitem'
122
- tabindex: "-1"
123
- aria-checked: item.getAttribute('aria-checked')?!
124
- $highlighted: (idx is highlightedIndex)?!
125
- $disabled: item.dataset.disabled?!
126
- @click: (=> @selectIndex(idx))
127
- @mouseenter: (=> highlightedIndex = idx)
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
@@ -1,7 +1,7 @@
1
1
  # Popover — accessible headless popover with anchor positioning
2
2
  #
3
- # Positions itself relative to the trigger. Dismisses on Escape or click outside.
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
- if trigger
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.addEventListener 'click', => @toggle()
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
- @toggle()
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 (=> @openPopover()), @hoverDelay
69
+ _hoverTimer = setTimeout (=> open = true), @hoverDelay
41
70
  trigger.addEventListener 'mouseleave', =>
42
71
  clearTimeout _hoverTimer if _hoverTimer
43
- _hoverCloseTimer = setTimeout (=> @close()), @hoverCloseDelay
72
+ _hoverCloseTimer = setTimeout (=> open = false), @hoverCloseDelay
73
+ @_applyPlacement()
44
74
 
45
75
  toggle: ->
46
76
  return if @disabled
47
- if open then @close() else @openPopover()
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
- if open
112
- floating.style.position = 'fixed'
113
- floating.style.left = '-9999px'
114
- floating.style.top = '-9999px'
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
- if open
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. Ships zero CSS.
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
- return unless trigger
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; @_position()), @delay
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
- _position: ->
43
- trigger = @_root?.querySelector('[data-trigger]')
50
+ _applyPlacement: ->
44
51
  floating = @_root?.querySelector('[data-content]')
45
- return unless trigger and floating
46
- @_root.style.position = 'relative'
47
- tr = trigger.getBoundingClientRect()
48
- cr = @_root.getBoundingClientRect()
49
- floating.style.position = 'absolute'
50
- floating.style.left = "0px"
51
- floating.style.top = "#{tr.bottom - cr.top + 4}px"
52
- floating.style.zIndex = '50'
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
- floating.hidden = not open
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"
@@ -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
- _position: -> ARIA.positionBelow @_trigger, @_list
109
-
110
- ~> ARIA.popupDismiss open, (=> @_list), (=> @close()), [=> @_trigger], (=> @_position())
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
- if open
134
- div ref: "_list", id: _listId, role: "listbox", style: "position:fixed;visibility:hidden"
135
- $open: true
136
- @keydown: @onListKeydown
137
- for opt, idx in options
138
- div role: "option"
139
- tabindex: "-1"
140
- $value: @getOpt(opt)
141
- $highlighted: (idx is highlightedIndex)?!
142
- $selected: (@getOpt(opt) is String(@value))?!
143
- $disabled: @isDisabled(opt)?!
144
- aria-selected: @getOpt(opt) is String(@value)
145
- aria-disabled: @isDisabled(opt)?!
146
- @click: (=> @selectIndex(idx))
147
- @mouseenter: (=> highlightedIndex = idx)
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
@@ -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
- if open
108
- div ref: "_tip", id: _id, role: "tooltip", style: "position:fixed"
109
- $open: true
110
- $entering: entering?!
111
- $exiting: exiting?!
112
- $placement: @placement
113
- @mouseenter: (=> @_cancelHide() if @hoverable)
114
- @mouseleave: (=> @hide() if @hoverable)
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.54",
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.107"
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
  }