rip-lang 3.13.119 → 3.13.121
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 +11 -2
- package/docs/RIP-LANG.md +4 -0
- package/docs/dist/rip.js +257 -27
- package/docs/dist/rip.min.js +183 -183
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/ui/accordion.rip +103 -0
- package/docs/ui/alert-dialog.rip +53 -0
- package/docs/ui/autocomplete.rip +115 -0
- package/docs/ui/avatar.rip +37 -0
- package/docs/ui/badge.rip +15 -0
- package/docs/ui/breadcrumb.rip +47 -0
- package/docs/ui/button-group.rip +26 -0
- package/docs/ui/button.rip +23 -0
- package/docs/ui/card.rip +25 -0
- package/docs/ui/carousel.rip +110 -0
- package/docs/ui/checkbox-group.rip +61 -0
- package/docs/ui/checkbox.rip +33 -0
- package/docs/ui/collapsible.rip +50 -0
- package/docs/ui/combobox.rip +130 -0
- package/docs/ui/context-menu.rip +88 -0
- package/docs/ui/date-picker.rip +206 -0
- package/docs/ui/dialog.rip +60 -0
- package/docs/ui/drawer.rip +58 -0
- package/docs/ui/editable-value.rip +82 -0
- package/docs/ui/field.rip +53 -0
- package/docs/ui/fieldset.rip +22 -0
- package/docs/ui/form.rip +39 -0
- package/docs/ui/grid.rip +901 -0
- package/docs/ui/hljs-rip.js +209 -0
- package/docs/ui/index.css +1797 -0
- package/docs/ui/index.html +2385 -0
- package/docs/ui/input-group.rip +28 -0
- package/docs/ui/input.rip +36 -0
- package/docs/ui/label.rip +16 -0
- package/docs/ui/menu.rip +134 -0
- package/docs/ui/menubar.rip +151 -0
- package/docs/ui/meter.rip +36 -0
- package/docs/ui/multi-select.rip +203 -0
- package/docs/ui/native-select.rip +33 -0
- package/docs/ui/nav-menu.rip +126 -0
- package/docs/ui/number-field.rip +162 -0
- package/docs/ui/otp-field.rip +89 -0
- package/docs/ui/pagination.rip +123 -0
- package/docs/ui/popover.rip +93 -0
- package/docs/ui/preview-card.rip +75 -0
- package/docs/ui/progress.rip +25 -0
- package/docs/ui/radio-group.rip +57 -0
- package/docs/ui/resizable.rip +123 -0
- package/docs/ui/scroll-area.rip +145 -0
- package/docs/ui/select.rip +151 -0
- package/docs/ui/separator.rip +17 -0
- package/docs/ui/skeleton.rip +22 -0
- package/docs/ui/slider.rip +165 -0
- package/docs/ui/spinner.rip +17 -0
- package/docs/ui/table.rip +27 -0
- package/docs/ui/tabs.rip +113 -0
- package/docs/ui/textarea.rip +48 -0
- package/docs/ui/toast.rip +87 -0
- package/docs/ui/toggle-group.rip +71 -0
- package/docs/ui/toggle.rip +24 -0
- package/docs/ui/toolbar.rip +38 -0
- package/docs/ui/tooltip.rip +85 -0
- package/package.json +1 -1
- package/src/compiler.js +24 -12
- package/src/components.js +43 -6
- package/src/grammar/grammar.rip +2 -2
- package/src/lexer.js +26 -0
- package/src/parser.js +2 -2
- package/src/sourcemap-utils.js +91 -0
- package/src/typecheck.js +33 -8
- package/src/ui.rip +118 -2
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Table — accessible headless semantic table wrapper
|
|
2
|
+
#
|
|
3
|
+
# Lightweight wrapper for HTML tables with optional caption and
|
|
4
|
+
# striped rows. For data-heavy tables with virtual scrolling, use Grid.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Table caption: "Team members", striped: true
|
|
9
|
+
# thead
|
|
10
|
+
# tr
|
|
11
|
+
# th "Name"
|
|
12
|
+
# th "Role"
|
|
13
|
+
# tbody
|
|
14
|
+
# tr
|
|
15
|
+
# td "Alice"
|
|
16
|
+
# td "Engineer"
|
|
17
|
+
|
|
18
|
+
export Table = component
|
|
19
|
+
@caption:: string := ""
|
|
20
|
+
@striped:: boolean := false
|
|
21
|
+
|
|
22
|
+
render
|
|
23
|
+
div $striped: @striped?!
|
|
24
|
+
table
|
|
25
|
+
if @caption
|
|
26
|
+
caption @caption
|
|
27
|
+
slot
|
package/docs/ui/tabs.rip
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# Tabs — accessible headless tab widget
|
|
2
|
+
#
|
|
3
|
+
# Keyboard: ArrowLeft/Right (horizontal) or ArrowUp/Down (vertical) to navigate,
|
|
4
|
+
# Home/End for first/last. Manages focus via roving tabindex.
|
|
5
|
+
# Exposes $active on tabs and panels. Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Props:
|
|
8
|
+
# active — currently active tab id (two-way bindable)
|
|
9
|
+
# orientation — 'horizontal' (default) or 'vertical'
|
|
10
|
+
# activation — 'automatic' (default, selects on focus) or 'manual' (Enter/Space to select)
|
|
11
|
+
#
|
|
12
|
+
# Usage:
|
|
13
|
+
# Tabs active <=> currentTab
|
|
14
|
+
# div $tab: "one", "Tab One"
|
|
15
|
+
# div $tab: "two", "Tab Two"
|
|
16
|
+
# div $panel: "one"
|
|
17
|
+
# p "Content for tab one"
|
|
18
|
+
# div $panel: "two"
|
|
19
|
+
# p "Content for tab two"
|
|
20
|
+
|
|
21
|
+
export Tabs = component
|
|
22
|
+
@active:: any := null
|
|
23
|
+
@orientation:: "horizontal" | "vertical" := "horizontal"
|
|
24
|
+
@activation:: "automatic" | "manual" := "automatic"
|
|
25
|
+
_ready := false
|
|
26
|
+
_id =! "tabs-#{Math.random().toString(36).slice(2, 8)}"
|
|
27
|
+
activationDirection := 'none'
|
|
28
|
+
|
|
29
|
+
tabs ~=
|
|
30
|
+
return [] unless _ready
|
|
31
|
+
Array.from(@_content?.querySelectorAll('[data-tab]') or [])
|
|
32
|
+
|
|
33
|
+
panels ~=
|
|
34
|
+
return [] unless _ready
|
|
35
|
+
Array.from(@_content?.querySelectorAll('[data-panel]') or [])
|
|
36
|
+
|
|
37
|
+
mounted: ->
|
|
38
|
+
_ready = true
|
|
39
|
+
unless @active
|
|
40
|
+
@active = tabs[0]?.dataset.tab
|
|
41
|
+
|
|
42
|
+
~>
|
|
43
|
+
return unless _ready
|
|
44
|
+
tabs.forEach (el) -> el.hidden = true
|
|
45
|
+
panels.forEach (el) =>
|
|
46
|
+
id = el.dataset.panel
|
|
47
|
+
isActive = id is @active
|
|
48
|
+
el.id = "#{_id}-panel-#{id}"
|
|
49
|
+
el.setAttribute 'role', 'tabpanel'
|
|
50
|
+
el.setAttribute 'aria-labelledby', "#{_id}-tab-#{id}"
|
|
51
|
+
el.toggleAttribute 'hidden', not isActive
|
|
52
|
+
el.toggleAttribute 'data-active', isActive
|
|
53
|
+
|
|
54
|
+
_isDisabled: (el) -> el?.hasAttribute('data-disabled')
|
|
55
|
+
|
|
56
|
+
select: (id) ->
|
|
57
|
+
prev = @active
|
|
58
|
+
horiz = @orientation is 'horizontal'
|
|
59
|
+
if prev and id isnt prev
|
|
60
|
+
oldTab = tabs.find (t) -> t.dataset.tab is prev
|
|
61
|
+
newTab = tabs.find (t) -> t.dataset.tab is id
|
|
62
|
+
if oldTab and newTab
|
|
63
|
+
oldRect = oldTab.getBoundingClientRect()
|
|
64
|
+
newRect = newTab.getBoundingClientRect()
|
|
65
|
+
activationDirection = if horiz
|
|
66
|
+
if newRect.left > oldRect.left then 'right' else 'left'
|
|
67
|
+
else
|
|
68
|
+
if newRect.top > oldRect.top then 'down' else 'up'
|
|
69
|
+
@active = id
|
|
70
|
+
@emit 'change', id
|
|
71
|
+
|
|
72
|
+
_nextEnabled: (ids, from, dir) ->
|
|
73
|
+
len = ids.length
|
|
74
|
+
i = from
|
|
75
|
+
loop len
|
|
76
|
+
i = (i + dir) %% len
|
|
77
|
+
tab = tabs.find (t) -> t.dataset.tab is ids[i]
|
|
78
|
+
return ids[i] unless @_isDisabled(tab)
|
|
79
|
+
ids[from]
|
|
80
|
+
|
|
81
|
+
onKeydown: (e) ->
|
|
82
|
+
ids = tabs.map (t) -> t.dataset.tab
|
|
83
|
+
idx = ids.indexOf @active
|
|
84
|
+
return if idx is -1
|
|
85
|
+
move = (nextId) =>
|
|
86
|
+
return unless nextId
|
|
87
|
+
tabs.find((t) -> t.dataset.tab is nextId)?.focus()
|
|
88
|
+
@select(nextId) if @activation is 'automatic'
|
|
89
|
+
ARIA.rovingNav e, {
|
|
90
|
+
next: => move(@_nextEnabled(ids, idx, 1))
|
|
91
|
+
prev: => move(@_nextEnabled(ids, idx, -1))
|
|
92
|
+
first: => move(@_nextEnabled(ids, ids.length - 1, 1))
|
|
93
|
+
last: => move(@_nextEnabled(ids, 0, -1))
|
|
94
|
+
select: => @select(ids[idx]) if @activation is 'manual'
|
|
95
|
+
}, @orientation
|
|
96
|
+
|
|
97
|
+
render
|
|
98
|
+
.
|
|
99
|
+
div role: "tablist", aria-orientation: @orientation, data-activation-direction: activationDirection, @keydown: @onKeydown
|
|
100
|
+
for tab in tabs
|
|
101
|
+
button role: "tab"
|
|
102
|
+
id: "#{_id}-tab-#{tab.dataset.tab}"
|
|
103
|
+
aria-selected: tab.dataset.tab is @active
|
|
104
|
+
aria-controls: "#{_id}-panel-#{tab.dataset.tab}"
|
|
105
|
+
aria-disabled: @_isDisabled(tab)?!
|
|
106
|
+
tabindex: if @_isDisabled(tab) then '-1' else (tab.dataset.tab is @active ? '0' : '-1')
|
|
107
|
+
$active: (tab.dataset.tab is @active)?!
|
|
108
|
+
$disabled: @_isDisabled(tab)?!
|
|
109
|
+
@click: (=> @select(tab.dataset.tab) unless @_isDisabled(tab))
|
|
110
|
+
= tab.textContent
|
|
111
|
+
|
|
112
|
+
. ref: "_content"
|
|
113
|
+
slot
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# Textarea — accessible headless auto-resizing text area
|
|
2
|
+
#
|
|
3
|
+
# Tracks focus, validation, and disabled state via data attributes.
|
|
4
|
+
# Optional auto-resize adjusts height to fit content. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Textarea value <=> bio, placeholder: "Tell us about yourself"
|
|
8
|
+
# Textarea value <=> notes, autoResize: true, rows: 3
|
|
9
|
+
|
|
10
|
+
export Textarea = component
|
|
11
|
+
@value:: string := ""
|
|
12
|
+
@placeholder:: string := ""
|
|
13
|
+
@disabled:: boolean := false
|
|
14
|
+
@required:: boolean := false
|
|
15
|
+
@rows:: number := 3
|
|
16
|
+
@autoResize:: boolean := false
|
|
17
|
+
|
|
18
|
+
focused := false
|
|
19
|
+
touched := false
|
|
20
|
+
|
|
21
|
+
onInput: (e) ->
|
|
22
|
+
@value = e.target.value
|
|
23
|
+
@_resize(e.target) if @autoResize
|
|
24
|
+
|
|
25
|
+
onFocus: -> focused = true
|
|
26
|
+
onBlur: ->
|
|
27
|
+
focused = false
|
|
28
|
+
touched = true
|
|
29
|
+
|
|
30
|
+
_resize: (el) ->
|
|
31
|
+
el.style.height = 'auto'
|
|
32
|
+
el.style.height = "#{el.scrollHeight}px"
|
|
33
|
+
|
|
34
|
+
mounted: ->
|
|
35
|
+
@_resize(@_root) if @autoResize and @value
|
|
36
|
+
|
|
37
|
+
render
|
|
38
|
+
textarea ref: "_root", value: @value, placeholder: @placeholder, rows: @rows
|
|
39
|
+
disabled: @disabled
|
|
40
|
+
required: @required
|
|
41
|
+
aria-disabled: @disabled?!
|
|
42
|
+
aria-required: @required?!
|
|
43
|
+
$disabled: @disabled?!
|
|
44
|
+
$focused: focused?!
|
|
45
|
+
$touched: touched?!
|
|
46
|
+
@input: @onInput
|
|
47
|
+
@focusin: @onFocus
|
|
48
|
+
@focusout: @onBlur
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# Toast — accessible headless toast notification system
|
|
2
|
+
#
|
|
3
|
+
# Managed toast system with stacking, timer pause on hover, and promise support.
|
|
4
|
+
# Uses ARIA live region for screen reader announcements. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# toasts := []
|
|
8
|
+
#
|
|
9
|
+
# # Add a toast — reactive assignment is the API
|
|
10
|
+
# toasts = [...toasts, { message: "Saved!", type: "success" }]
|
|
11
|
+
#
|
|
12
|
+
# # Dismiss — filter it out
|
|
13
|
+
# toasts = toasts.filter (t) -> t isnt target
|
|
14
|
+
#
|
|
15
|
+
# # Clear all
|
|
16
|
+
# toasts = []
|
|
17
|
+
#
|
|
18
|
+
# # In render block
|
|
19
|
+
# ToastViewport toasts <=> toasts
|
|
20
|
+
|
|
21
|
+
export ToastViewport = component
|
|
22
|
+
@toasts:: any[] := []
|
|
23
|
+
@placement:: "top-left" | "top-right" | "bottom-left" | "bottom-right" := "bottom-right"
|
|
24
|
+
|
|
25
|
+
_onDismiss: (toast) ->
|
|
26
|
+
@toasts = @toasts.filter (t) -> t isnt toast
|
|
27
|
+
|
|
28
|
+
render
|
|
29
|
+
div role: "region", aria-label: "Notifications", $placement: @placement
|
|
30
|
+
for toast in @toasts
|
|
31
|
+
Toast toast: toast, @dismiss: (e) => @_onDismiss(e.detail)
|
|
32
|
+
|
|
33
|
+
export Toast = component
|
|
34
|
+
@toast:: Record<string, any> := {}
|
|
35
|
+
|
|
36
|
+
leaving := false
|
|
37
|
+
_timer := null
|
|
38
|
+
_remaining = 0
|
|
39
|
+
_started = 0
|
|
40
|
+
|
|
41
|
+
_startTimer: ->
|
|
42
|
+
dur = @toast.duration ?? 4000
|
|
43
|
+
return unless dur > 0
|
|
44
|
+
_remaining = dur
|
|
45
|
+
_started = Date.now()
|
|
46
|
+
_timer = setTimeout => @dismiss(), dur
|
|
47
|
+
|
|
48
|
+
_pauseTimer: ->
|
|
49
|
+
return unless _timer
|
|
50
|
+
clearTimeout _timer
|
|
51
|
+
_remaining -= Date.now() - _started
|
|
52
|
+
_timer = null
|
|
53
|
+
|
|
54
|
+
_resumeTimer: ->
|
|
55
|
+
return if _timer or _remaining <= 0
|
|
56
|
+
_started = Date.now()
|
|
57
|
+
_timer = setTimeout => @dismiss(), _remaining
|
|
58
|
+
|
|
59
|
+
mounted: -> @_startTimer()
|
|
60
|
+
|
|
61
|
+
beforeUnmount: ->
|
|
62
|
+
clearTimeout _timer if _timer
|
|
63
|
+
|
|
64
|
+
dismiss: ->
|
|
65
|
+
leaving = true
|
|
66
|
+
setTimeout =>
|
|
67
|
+
@emit 'dismiss', @toast
|
|
68
|
+
, 200
|
|
69
|
+
|
|
70
|
+
render
|
|
71
|
+
div role: (if @toast.type is 'error' then 'alert' else 'status'),
|
|
72
|
+
aria-live: (if @toast.type is 'error' then 'assertive' else 'polite'),
|
|
73
|
+
$type: @toast.type ?? 'info',
|
|
74
|
+
$leaving: leaving?!,
|
|
75
|
+
@mouseenter: @_pauseTimer,
|
|
76
|
+
@mouseleave: @_resumeTimer,
|
|
77
|
+
@focusin: @_pauseTimer,
|
|
78
|
+
@focusout: @_resumeTimer
|
|
79
|
+
.
|
|
80
|
+
if @toast.title
|
|
81
|
+
strong @toast.title
|
|
82
|
+
span @toast.message
|
|
83
|
+
if @toast.action
|
|
84
|
+
button @click: @toast.action.onClick
|
|
85
|
+
@toast.action.label or 'Action'
|
|
86
|
+
button aria-label: "Dismiss", @click: @dismiss
|
|
87
|
+
"✕"
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# ToggleGroup — accessible headless toggle group
|
|
2
|
+
#
|
|
3
|
+
# A set of two-state buttons where one or more can be pressed.
|
|
4
|
+
# Set @multiple to false for single-select (radio-like) behavior.
|
|
5
|
+
# Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# ToggleGroup value <=> alignment
|
|
9
|
+
# div $value: "left", "Left"
|
|
10
|
+
# div $value: "center", "Center"
|
|
11
|
+
# div $value: "right", "Right"
|
|
12
|
+
|
|
13
|
+
export ToggleGroup = component
|
|
14
|
+
@value:: any := null
|
|
15
|
+
@disabled:: boolean := false
|
|
16
|
+
@multiple:: boolean := false
|
|
17
|
+
@orientation:: "horizontal" | "vertical" := "horizontal"
|
|
18
|
+
|
|
19
|
+
_items ~=
|
|
20
|
+
return [] unless @_slot
|
|
21
|
+
Array.from(@_slot.querySelectorAll('[data-value]') or [])
|
|
22
|
+
|
|
23
|
+
_isPressed: (item) ->
|
|
24
|
+
val = item.dataset.value
|
|
25
|
+
if @multiple
|
|
26
|
+
Array.isArray(@value) and val in @value
|
|
27
|
+
else
|
|
28
|
+
val is @value
|
|
29
|
+
|
|
30
|
+
_toggle: (val) ->
|
|
31
|
+
return if @disabled
|
|
32
|
+
if @multiple
|
|
33
|
+
arr = if Array.isArray(@value) then [...@value] else []
|
|
34
|
+
if val in arr
|
|
35
|
+
arr = arr.filter (v) -> v isnt val
|
|
36
|
+
else
|
|
37
|
+
arr.push val
|
|
38
|
+
@value = arr
|
|
39
|
+
else
|
|
40
|
+
@value = if val is @value then null else val
|
|
41
|
+
@emit 'change', @value
|
|
42
|
+
|
|
43
|
+
onKeydown: (e) ->
|
|
44
|
+
opts = @_root?.querySelectorAll('[data-value]')
|
|
45
|
+
return unless opts?.length
|
|
46
|
+
focused = Array.from(opts).indexOf(document.activeElement)
|
|
47
|
+
return if focused < 0
|
|
48
|
+
len = opts.length
|
|
49
|
+
ARIA.rovingNav e, {
|
|
50
|
+
next: => opts[(focused + 1) %% len]?.focus()
|
|
51
|
+
prev: => opts[(focused - 1) %% len]?.focus()
|
|
52
|
+
first: => opts[0]?.focus()
|
|
53
|
+
last: => opts[len - 1]?.focus()
|
|
54
|
+
}, 'both'
|
|
55
|
+
|
|
56
|
+
render
|
|
57
|
+
div ref: "_root", role: "group", aria-orientation: @orientation
|
|
58
|
+
$orientation: @orientation
|
|
59
|
+
$disabled: @disabled?!
|
|
60
|
+
|
|
61
|
+
. ref: "_slot", style: "display:none"
|
|
62
|
+
slot
|
|
63
|
+
|
|
64
|
+
for item, idx in _items
|
|
65
|
+
button tabindex: (if idx is 0 then "0" else "-1")
|
|
66
|
+
aria-pressed: !!@_isPressed(item)
|
|
67
|
+
$pressed: @_isPressed(item)?!
|
|
68
|
+
$disabled: @disabled?!
|
|
69
|
+
$value: item.dataset.value
|
|
70
|
+
@click: (=> @_toggle(item.dataset.value))
|
|
71
|
+
= item.textContent
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Toggle — accessible headless toggle button
|
|
2
|
+
#
|
|
3
|
+
# Stateful button that toggles pressed state on click.
|
|
4
|
+
# Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Toggle pressed <=> isBold
|
|
8
|
+
# "Bold"
|
|
9
|
+
|
|
10
|
+
export Toggle = component
|
|
11
|
+
@pressed:: boolean := false
|
|
12
|
+
@disabled:: boolean := false
|
|
13
|
+
|
|
14
|
+
onClick: ->
|
|
15
|
+
return if @disabled
|
|
16
|
+
@pressed = not @pressed
|
|
17
|
+
@emit 'change', @pressed
|
|
18
|
+
|
|
19
|
+
render
|
|
20
|
+
button aria-pressed: !!@pressed
|
|
21
|
+
aria-disabled: @disabled?!
|
|
22
|
+
$pressed: @pressed?!
|
|
23
|
+
$disabled: @disabled?!
|
|
24
|
+
slot
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# Toolbar — accessible headless toolbar
|
|
2
|
+
#
|
|
3
|
+
# Groups interactive controls with roving tabindex keyboard navigation.
|
|
4
|
+
# Arrow keys move focus between focusable children. Ships zero CSS.
|
|
5
|
+
#
|
|
6
|
+
# Usage:
|
|
7
|
+
# Toolbar
|
|
8
|
+
# Button @click: save, "Save"
|
|
9
|
+
# Button @click: undo, "Undo"
|
|
10
|
+
# Separator orientation: "vertical"
|
|
11
|
+
# Toggle pressed <=> isBold, "Bold"
|
|
12
|
+
|
|
13
|
+
export Toolbar = component
|
|
14
|
+
@orientation:: "horizontal" | "vertical" := "horizontal"
|
|
15
|
+
@label:: string := ""
|
|
16
|
+
|
|
17
|
+
_getFocusable: ->
|
|
18
|
+
return [] unless @_root
|
|
19
|
+
Array.from(@_root.querySelectorAll('button, [tabindex], input, select, textarea')).filter (el) ->
|
|
20
|
+
not el.disabled and el.offsetParent isnt null
|
|
21
|
+
|
|
22
|
+
onKeydown: (e) ->
|
|
23
|
+
els = @_getFocusable()
|
|
24
|
+
return unless els.length
|
|
25
|
+
focused = els.indexOf(document.activeElement)
|
|
26
|
+
return if focused < 0
|
|
27
|
+
len = els.length
|
|
28
|
+
ARIA.rovingNav e, {
|
|
29
|
+
next: => els[(focused + 1) %% len]?.focus()
|
|
30
|
+
prev: => els[(focused - 1) %% len]?.focus()
|
|
31
|
+
first: => els[0]?.focus()
|
|
32
|
+
last: => els[len - 1]?.focus()
|
|
33
|
+
}, @orientation
|
|
34
|
+
|
|
35
|
+
render
|
|
36
|
+
div role: "toolbar", aria-label: @label or undefined, aria-orientation: @orientation
|
|
37
|
+
$orientation: @orientation
|
|
38
|
+
slot
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# Tooltip — accessible headless tooltip with delay and positioning
|
|
2
|
+
#
|
|
3
|
+
# Shows on hover/focus with configurable delay. Uses aria-describedby and
|
|
4
|
+
# native `popover="hint"` for top-layer behavior.
|
|
5
|
+
# Exposes $open, $entering, $exiting. Ships zero CSS.
|
|
6
|
+
#
|
|
7
|
+
# Usage:
|
|
8
|
+
# Tooltip text: "Helpful info", placement: "top"
|
|
9
|
+
# button "Hover me"
|
|
10
|
+
|
|
11
|
+
lastCloseTime = 0
|
|
12
|
+
GROUP_TIMEOUT = 400
|
|
13
|
+
|
|
14
|
+
export Tooltip = component
|
|
15
|
+
@text:: string := ""
|
|
16
|
+
@placement:: "top" | "top-start" | "top-end" | "bottom" | "bottom-start" | "bottom-end" | "left" | "right" := "top"
|
|
17
|
+
@delay:: number := 300
|
|
18
|
+
@offset:: number := 6
|
|
19
|
+
@hoverable:: boolean := false
|
|
20
|
+
|
|
21
|
+
open := false
|
|
22
|
+
entering := false
|
|
23
|
+
exiting := false
|
|
24
|
+
_showTimer := null
|
|
25
|
+
_hideTimer := null
|
|
26
|
+
_id =! "tip-#{Math.random().toString(36).slice(2, 8)}"
|
|
27
|
+
|
|
28
|
+
_applyPlacement: ->
|
|
29
|
+
[side, align] = @placement.split('-')
|
|
30
|
+
align ??= 'center'
|
|
31
|
+
ARIA.position @_trigger, @_tip, placement: "#{side} #{align}", offset: @offset
|
|
32
|
+
|
|
33
|
+
show: ->
|
|
34
|
+
clearTimeout _hideTimer if _hideTimer
|
|
35
|
+
delay = if (Date.now() - lastCloseTime) < GROUP_TIMEOUT then 0 else @delay
|
|
36
|
+
_showTimer = setTimeout =>
|
|
37
|
+
open = true
|
|
38
|
+
entering = true
|
|
39
|
+
setTimeout =>
|
|
40
|
+
entering = false
|
|
41
|
+
, 0
|
|
42
|
+
, delay
|
|
43
|
+
|
|
44
|
+
hide: ->
|
|
45
|
+
clearTimeout _showTimer if _showTimer
|
|
46
|
+
exiting = true
|
|
47
|
+
_hideTimer = setTimeout =>
|
|
48
|
+
open = false
|
|
49
|
+
exiting = false
|
|
50
|
+
lastCloseTime = Date.now()
|
|
51
|
+
, 150
|
|
52
|
+
|
|
53
|
+
_cancelHide: ->
|
|
54
|
+
clearTimeout _hideTimer if _hideTimer
|
|
55
|
+
exiting = false
|
|
56
|
+
|
|
57
|
+
beforeUnmount: ->
|
|
58
|
+
clearTimeout _showTimer if _showTimer
|
|
59
|
+
clearTimeout _hideTimer if _hideTimer
|
|
60
|
+
|
|
61
|
+
~>
|
|
62
|
+
if @_tip
|
|
63
|
+
@_tip.setAttribute 'popover', 'hint'
|
|
64
|
+
@_applyPlacement()
|
|
65
|
+
if open then @_tip.setAttribute('data-open', '') else @_tip.removeAttribute('data-open')
|
|
66
|
+
ARIA.bindPopover open, (=> @_tip), ((isOpen) => open = isOpen), (=> @_trigger)
|
|
67
|
+
|
|
68
|
+
render
|
|
69
|
+
.
|
|
70
|
+
div ref: "_trigger"
|
|
71
|
+
aria-describedby: open ? _id : undefined
|
|
72
|
+
@mouseenter: @show
|
|
73
|
+
@mouseleave: @hide
|
|
74
|
+
@focusin: @show
|
|
75
|
+
@focusout: @hide
|
|
76
|
+
slot
|
|
77
|
+
|
|
78
|
+
div ref: "_tip", id: _id, role: "tooltip", style: "position:fixed;margin:0;inset:auto"
|
|
79
|
+
$open: open?!
|
|
80
|
+
$entering: entering?!
|
|
81
|
+
$exiting: exiting?!
|
|
82
|
+
$placement: @placement
|
|
83
|
+
@mouseenter: (=> @_cancelHide() if @hoverable)
|
|
84
|
+
@mouseleave: (=> @hide() if @hoverable)
|
|
85
|
+
@text
|
package/package.json
CHANGED
package/src/compiler.js
CHANGED
|
@@ -341,17 +341,27 @@ export class CodeGenerator {
|
|
|
341
341
|
// Collect identifier anchors from sub-expression nodes with .loc.
|
|
342
342
|
collectSubExprs(node, result) {
|
|
343
343
|
if (!Array.isArray(node)) return;
|
|
344
|
+
// If node[0] is not a string-like head (e.g. array-of-arrays like the cases
|
|
345
|
+
// list in a switch), recurse into ALL children including index 0.
|
|
346
|
+
let head = node[0];
|
|
347
|
+
if (Array.isArray(head) || (head != null && typeof head !== 'string' && !(head instanceof String))) {
|
|
348
|
+
for (let i = 0; i < node.length; i++) {
|
|
349
|
+
if (Array.isArray(node[i])) this.collectSubExprs(node[i], result);
|
|
350
|
+
}
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
344
353
|
if (node.loc) {
|
|
345
|
-
|
|
354
|
+
head = str(head);
|
|
346
355
|
let ident = null;
|
|
356
|
+
// Property access: anchor is the property name (check BEFORE operators
|
|
357
|
+
// because the operator regex also matches '.' via ^\.\.?$)
|
|
358
|
+
if (head === '.') {
|
|
359
|
+
if (typeof node[2] === 'string') ident = node[2];
|
|
360
|
+
}
|
|
347
361
|
// Operators/keywords: anchor is the subject at index 1
|
|
348
|
-
if (typeof head === 'string' && /^[=+\-*/%<>!&|?~^]|^\.\.?$|^def$|^class$|^state$|^computed$|^readonly$|^for-/.test(head)) {
|
|
362
|
+
else if (typeof head === 'string' && /^[=+\-*/%<>!&|?~^]|^\.\.?$|^def$|^class$|^state$|^computed$|^readonly$|^for-/.test(head)) {
|
|
349
363
|
if (typeof node[1] === 'string' && /^[a-zA-Z_$]/.test(node[1])) ident = node[1];
|
|
350
364
|
}
|
|
351
|
-
// Property access: anchor is the property name
|
|
352
|
-
else if (head === '.') {
|
|
353
|
-
if (typeof node[2] === 'string') ident = node[2];
|
|
354
|
-
}
|
|
355
365
|
// Function call (head is identifier)
|
|
356
366
|
else if (typeof head === 'string' && /^[a-zA-Z_$]/.test(head)) {
|
|
357
367
|
ident = head;
|
|
@@ -396,6 +406,7 @@ export class CodeGenerator {
|
|
|
396
406
|
return;
|
|
397
407
|
}
|
|
398
408
|
if (head === 'component') return; // Component body has its own scope
|
|
409
|
+
if (head === 'enum') return; // Enum members are not top-level variables
|
|
399
410
|
|
|
400
411
|
if (CodeGenerator.ASSIGNMENT_OPS.has(head)) {
|
|
401
412
|
let [target, value] = rest;
|
|
@@ -2122,17 +2133,18 @@ export class CodeGenerator {
|
|
|
2122
2133
|
return importExpr;
|
|
2123
2134
|
}
|
|
2124
2135
|
if (this.options.skipImports) return '';
|
|
2136
|
+
if (rest.length === 3) {
|
|
2137
|
+
let [def, named, source] = rest;
|
|
2138
|
+
let fixedSource = this.addJsExtensionAndAssertions(source);
|
|
2139
|
+
if (named[0] === '*' && named.length === 2) return `import ${def}, * as ${named[1]} from ${fixedSource}`;
|
|
2140
|
+
let names = named.map(i => Array.isArray(i) && i.length === 2 ? `${i[0]} as ${i[1]}` : i).join(', ');
|
|
2141
|
+
return `import ${def}, { ${names} } from ${fixedSource}`;
|
|
2142
|
+
}
|
|
2125
2143
|
let [specifier, source] = rest;
|
|
2126
2144
|
let fixedSource = this.addJsExtensionAndAssertions(source);
|
|
2127
2145
|
if (typeof specifier === 'string') return `import ${specifier} from ${fixedSource}`;
|
|
2128
2146
|
if (Array.isArray(specifier)) {
|
|
2129
2147
|
if (specifier[0] === '*' && specifier.length === 2) return `import * as ${specifier[1]} from ${fixedSource}`;
|
|
2130
|
-
if (typeof specifier[0] === 'string' && Array.isArray(specifier[1])) {
|
|
2131
|
-
let def = specifier[0], second = specifier[1];
|
|
2132
|
-
if (second[0] === '*' && second.length === 2) return `import ${def}, * as ${second[1]} from ${fixedSource}`;
|
|
2133
|
-
let names = (Array.isArray(second) ? second : [second]).map(i => Array.isArray(i) && i.length === 2 ? `${i[0]} as ${i[1]}` : i).join(', ');
|
|
2134
|
-
return `import ${def}, { ${names} } from ${fixedSource}`;
|
|
2135
|
-
}
|
|
2136
2148
|
let names = specifier.map(i => Array.isArray(i) && i.length === 2 ? `${i[0]} as ${i[1]}` : i).join(', ');
|
|
2137
2149
|
return `import { ${names} } from ${fixedSource}`;
|
|
2138
2150
|
}
|