@leftoff/cli 0.9.7 → 0.9.8-beta.2
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/CHANGELOG.md +21 -0
- package/bin/leftoff.js +351 -326
- package/docs/SECURITY.md +10 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/core.js +2 -2
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +221 -0
- package/ui/app.js +442 -20
- package/ui/index.html +14 -6
- package/ui/live-status.js +89 -0
- package/ui/live-view.js +171 -0
- package/ui/nav.js +88 -0
- package/ui/needs.js +33 -2
- package/ui/pull.js +31 -0
- package/ui/restore-gate.js +26 -2
- package/ui/sw.js +14 -1
- package/ui/tips.js +117 -0
package/ui/tips.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// 0.9.8 info tips: a small ⓘ button beside a control, with the details in a
|
|
2
|
+
// tooltip. Hover or keyboard focus on the ⓘ shows it (desktop); a tap pins it
|
|
3
|
+
// open as a small popover (touch); a second tap, Escape or a click outside
|
|
4
|
+
// closes it. The tip is a role="tooltip" the control and the ⓘ both point at
|
|
5
|
+
// with aria-describedby, so screen readers get the words without opening it.
|
|
6
|
+
// tipHtml() is pure (the unit tests read it); wireTips() runs once in the board.
|
|
7
|
+
|
|
8
|
+
import { icon } from './icons.js'
|
|
9
|
+
|
|
10
|
+
const ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }
|
|
11
|
+
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ESC[c])
|
|
12
|
+
|
|
13
|
+
// A DOM id from any string (an ask id can carry anything).
|
|
14
|
+
export const tipId = (scope, key) => 'tip-' + String(scope || 't').replace(/[^A-Za-z0-9_-]/g, '_') + '-' + String(key || 'x').replace(/[^A-Za-z0-9_-]/g, '_')
|
|
15
|
+
|
|
16
|
+
// The ⓘ button and its (hidden) tooltip. `html` is already escaped markup.
|
|
17
|
+
export function tipHtml({ id, label, html }) {
|
|
18
|
+
return '<button type="button" class="tip-btn" aria-label="' + esc(label) + '" aria-expanded="false" aria-controls="' + esc(id) + '" aria-describedby="' + esc(id) + '">' + icon('info') + '</button>' +
|
|
19
|
+
'<span class="tip" role="tooltip" id="' + esc(id) + '" hidden>' + html + '</span>'
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const OPEN_DELAY = 120
|
|
23
|
+
const CLOSE_DELAY = 180
|
|
24
|
+
|
|
25
|
+
export function wireTips(doc = document) {
|
|
26
|
+
const win = doc.defaultView
|
|
27
|
+
let cur = null // { btn, tip, pinned }
|
|
28
|
+
let timer = 0
|
|
29
|
+
const later = (fn, ms) => { win.clearTimeout(timer); timer = win.setTimeout(fn, ms) }
|
|
30
|
+
const tipOf = (btn) => btn && doc.getElementById(btn.getAttribute('aria-controls'))
|
|
31
|
+
|
|
32
|
+
function place(btn, tip) {
|
|
33
|
+
const r = btn.getBoundingClientRect()
|
|
34
|
+
const vh = win.innerHeight
|
|
35
|
+
// kept on screen, and inside a small popover (Quick look) when it sits in one
|
|
36
|
+
const box = btn.closest('.quicklook')
|
|
37
|
+
const b = box ? box.getBoundingClientRect() : { left: 0, right: doc.documentElement.clientWidth }
|
|
38
|
+
const lo = b.left + 8
|
|
39
|
+
const hi = b.right - 8
|
|
40
|
+
tip.style.maxWidth = Math.min(320, hi - lo) + 'px'
|
|
41
|
+
const w = tip.offsetWidth
|
|
42
|
+
const h = tip.offsetHeight
|
|
43
|
+
let left = r.right - w // right-aligned with the ⓘ
|
|
44
|
+
left = Math.max(lo, Math.min(left, hi - w))
|
|
45
|
+
let top = r.bottom + 6
|
|
46
|
+
let up = false
|
|
47
|
+
if (top + h > vh - 8 && r.top - 6 - h >= 8) { top = r.top - 6 - h; up = true }
|
|
48
|
+
tip.style.left = Math.round(left) + 'px'
|
|
49
|
+
tip.style.top = Math.round(top) + 'px'
|
|
50
|
+
tip.classList.toggle('up', up)
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function show(btn, pinned) {
|
|
54
|
+
const tip = tipOf(btn)
|
|
55
|
+
if (!tip) return
|
|
56
|
+
win.clearTimeout(timer)
|
|
57
|
+
if (cur && cur.btn !== btn) hide()
|
|
58
|
+
tip.hidden = false
|
|
59
|
+
btn.setAttribute('aria-expanded', 'true')
|
|
60
|
+
place(btn, tip)
|
|
61
|
+
cur = { btn, tip, pinned: !!pinned || !!(cur && cur.btn === btn && cur.pinned) }
|
|
62
|
+
}
|
|
63
|
+
function hide() {
|
|
64
|
+
win.clearTimeout(timer)
|
|
65
|
+
if (!cur) return
|
|
66
|
+
cur.tip.hidden = true
|
|
67
|
+
if (cur.btn.isConnected) cur.btn.setAttribute('aria-expanded', 'false')
|
|
68
|
+
cur = null
|
|
69
|
+
}
|
|
70
|
+
const inside = (t) => !!(cur && t && t.closest && (cur.btn.contains(t) || cur.tip.contains(t)))
|
|
71
|
+
|
|
72
|
+
doc.addEventListener('click', (e) => {
|
|
73
|
+
if (cur && !cur.tip.isConnected) cur = null // the list was redrawn under it
|
|
74
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
75
|
+
if (btn) {
|
|
76
|
+
e.preventDefault(); e.stopPropagation()
|
|
77
|
+
if (cur && cur.btn === btn && cur.pinned) return hide()
|
|
78
|
+
return show(btn, true)
|
|
79
|
+
}
|
|
80
|
+
if (cur && !inside(e.target)) hide()
|
|
81
|
+
}, true)
|
|
82
|
+
// Mouse: hover the ⓘ (or the tip itself, so it can be read and selected)
|
|
83
|
+
doc.addEventListener('pointerover', (e) => {
|
|
84
|
+
if (e.pointerType !== 'mouse') return
|
|
85
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
86
|
+
if (btn) return (cur && cur.btn === btn) ? win.clearTimeout(timer) : later(() => show(btn, false), OPEN_DELAY)
|
|
87
|
+
if (inside(e.target)) win.clearTimeout(timer)
|
|
88
|
+
})
|
|
89
|
+
doc.addEventListener('pointerout', (e) => {
|
|
90
|
+
if (e.pointerType !== 'mouse') return
|
|
91
|
+
const from = e.target.closest && e.target.closest('.tip-btn, .tip')
|
|
92
|
+
if (!from) return
|
|
93
|
+
if (inside(e.relatedTarget)) return
|
|
94
|
+
if (!cur) return win.clearTimeout(timer)
|
|
95
|
+
if (!cur.pinned) later(hide, CLOSE_DELAY)
|
|
96
|
+
})
|
|
97
|
+
// Keyboard: focus on the ⓘ shows it, leaving it hides it (unless tapped open)
|
|
98
|
+
doc.addEventListener('focusin', (e) => {
|
|
99
|
+
const btn = e.target.closest && e.target.closest('.tip-btn')
|
|
100
|
+
if (btn && btn.matches(':focus-visible')) show(btn, false)
|
|
101
|
+
})
|
|
102
|
+
doc.addEventListener('focusout', (e) => {
|
|
103
|
+
if (cur && !cur.pinned && e.target === cur.btn && !inside(e.relatedTarget)) hide()
|
|
104
|
+
})
|
|
105
|
+
// Escape closes it first (before Quick look or a dialog hears the key)
|
|
106
|
+
doc.addEventListener('keydown', (e) => {
|
|
107
|
+
if (e.key !== 'Escape' || !cur) return
|
|
108
|
+
e.preventDefault(); e.stopImmediatePropagation()
|
|
109
|
+
const b = cur.btn
|
|
110
|
+
const back = doc.activeElement && doc.activeElement !== b && cur.tip.contains(doc.activeElement)
|
|
111
|
+
hide()
|
|
112
|
+
if (back) b.focus()
|
|
113
|
+
}, true)
|
|
114
|
+
win.addEventListener('resize', () => { if (cur) place(cur.btn, cur.tip) })
|
|
115
|
+
win.addEventListener('scroll', () => { if (cur) { if (!cur.btn.isConnected) hide(); else place(cur.btn, cur.tip) } }, true)
|
|
116
|
+
return { hide, isOpen: () => !!cur }
|
|
117
|
+
}
|