@leftoff/cli 0.8.6 → 0.8.7
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 +5 -0
- package/mod/.claude-plugin/plugin.json +1 -1
- package/mod/hooks/register.js +1 -1
- package/package.json +1 -1
- package/ui/app.css +23 -0
- package/ui/app.js +62 -15
- package/ui/icons.js +8 -1
- package/ui/picker.js +106 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
|
|
3
3
|
What changed in Leftoff (called Checkpoints until 0.4.5), newest first. The board's **What's new** page and `leftoff changelog` both read this file.
|
|
4
4
|
|
|
5
|
+
## [0.8.7] - 2026-10-13
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- Session cards keep their buttons on one row: the main button (Go or Resume), a copy icon for the resume command, and **⋯** for Checkpoint now, Pin, Archive and Done with it.
|
|
9
|
+
|
|
5
10
|
## [0.8.6] - 2026-10-13
|
|
6
11
|
|
|
7
12
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Leftoff (formerly Checkpoints): WTF was I working on? Tracks every Claude Code session on this machine, writes a 3-line checkpoint when one goes quiet, and adds /cp (checkpoint now), /cp-name and a /leftoff pane. Talks to the local Leftoff daemon on 127.0.0.1:7429 and falls back to writing markdown in ~/checkpoints.",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Joel"
|
package/mod/hooks/register.js
CHANGED
|
@@ -28,7 +28,7 @@ const END_TIMEOUT_MS = 1000
|
|
|
28
28
|
const MODEL_TIMEOUT_MS = 20000
|
|
29
29
|
|
|
30
30
|
// Module state. $.state is not used: the pane redraws with $.ui.invalidate.
|
|
31
|
-
const MOD_VERSION = '0.8.
|
|
31
|
+
const MOD_VERSION = '0.8.7' // keep in step with .claude-plugin/plugin.json
|
|
32
32
|
|
|
33
33
|
const st = {
|
|
34
34
|
internal: false,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@leftoff/cli",
|
|
3
|
-
"version": "0.8.
|
|
3
|
+
"version": "0.8.7",
|
|
4
4
|
"description": "Leftoff: WTF was I working on? Tracks every Claude Code session, writes a 3-line checkpoint when one goes quiet, and gives you a daily digest and a board, on your Mac and (optionally) your phone.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"claude",
|
package/ui/app.css
CHANGED
|
@@ -1630,3 +1630,26 @@ html.dv-open, html.dv-open body { overflow: hidden; }
|
|
|
1630
1630
|
.emoji-sheet-wrap { align-items: flex-end; }
|
|
1631
1631
|
.emoji-sheet { width: 100vw; border-radius: 16px 16px 0 0; padding-bottom: calc(14px + env(safe-area-inset-bottom)); }
|
|
1632
1632
|
}
|
|
1633
|
+
|
|
1634
|
+
/* 0.8.7: a card's actions are one row: main button, copy icon, ⋯ (the rest in a menu) */
|
|
1635
|
+
.card > .actions-row { flex-wrap: nowrap; align-items: center; min-width: 0; }
|
|
1636
|
+
.card > .actions-row > .btn:not(.icon-btn), .card > .actions-row > .go-off { min-width: 0; flex: 0 1 auto; }
|
|
1637
|
+
.card > .actions-row > .btn:not(.icon-btn) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: inline-block; line-height: 1.45; }
|
|
1638
|
+
.card > .actions-row > .btn:not(.icon-btn) .ic { vertical-align: -2px; margin-right: 5px; }
|
|
1639
|
+
.card > .actions-row > .go-off { display: inline-flex; align-items: center; gap: 6px; overflow: hidden; }
|
|
1640
|
+
.card > .actions-row > .go-off .why { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
|
1641
|
+
.card > .actions-row > .ro { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1642
|
+
.btn.icon-btn { flex: none; width: 30px; height: 28px; padding: 0; justify-content: center; }
|
|
1643
|
+
.btn.icon-btn .ic { width: 15px; height: 15px; margin: 0; }
|
|
1644
|
+
.card > .actions-row > .btn.more { margin-left: auto; }
|
|
1645
|
+
.btn.copy-resume.copied { color: var(--green, #2fbf71); border-color: currentColor; }
|
|
1646
|
+
.lo-actmenu { min-width: 190px; max-width: min(320px, calc(100vw - 16px)); overflow-y: auto; }
|
|
1647
|
+
.lo-act { display: flex; align-items: center; gap: 10px; width: 100%; padding: 8px 10px; border: 0; border-radius: 8px; background: none; color: var(--text2); font: inherit; font-size: 14px; text-align: left; cursor: pointer; white-space: nowrap; }
|
|
1648
|
+
.lo-act .ic { width: 16px; height: 16px; flex: none; color: var(--muted); }
|
|
1649
|
+
.lo-act:hover, .lo-act:focus { outline: none; background: var(--bg2); color: var(--text); }
|
|
1650
|
+
[data-theme="dark"] .lo-act:hover, [data-theme="dark"] .lo-act:focus, :root:not([data-theme="light"]) .lo-act:hover, :root:not([data-theme="light"]) .lo-act:focus { background: var(--line); }
|
|
1651
|
+
.lo-act:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
|
|
1652
|
+
.lo-act:disabled { opacity: .55; cursor: progress; }
|
|
1653
|
+
.lo-menu.sheet.lo-actmenu { max-width: none; }
|
|
1654
|
+
.lo-menu.sheet .lo-act { padding: 14px 10px; font-size: 16px; }
|
|
1655
|
+
@media (pointer: coarse) { .btn.icon-btn { width: 36px; height: 34px; } }
|
package/ui/app.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// /v1 API on the same origin (auth rides the cookie set at GET /).
|
|
3
3
|
import { goWithFeedback, goState, GIVE_UP_MS } from './go.js'
|
|
4
4
|
import { icon } from './icons.js'
|
|
5
|
-
import { selectHtml, comboHtml, closeMenu } from './picker.js'
|
|
5
|
+
import { selectHtml, comboHtml, closeMenu, openActionMenu, closeActionMenu, actionMenuKey } from './picker.js'
|
|
6
6
|
import { SETTINGS, settingsKeyFor } from './settings-nav.js'
|
|
7
7
|
import { inStatus, repoChips, applyFilter, readFilter, saveRepos, filterHash, toggleRepo, chipSelected, splitChips, repoMatches, orderSelectedFirst, searchMatches } from './repos.js'
|
|
8
8
|
import { needsItems, needsStrip, needPanel, agentNeedPanel, httpsOnly, agentReplyButton, agentChipHtml, cardBadge, questionAnswer, turnsHtml, dismissedHtml } from './needs.js'
|
|
@@ -98,7 +98,13 @@ function toastUndo(msg, onUndo, ms = 5000) {
|
|
|
98
98
|
toast.t = setTimeout(() => t.classList.remove('show', 'has-action'), ms)
|
|
99
99
|
}
|
|
100
100
|
async function copy(text) {
|
|
101
|
-
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text) } catch { prompt('Copy this:', text) }
|
|
101
|
+
try { await navigator.clipboard.writeText(text); toast('Copied: ' + text); return true } catch { prompt('Copy this:', text); return false }
|
|
102
|
+
}
|
|
103
|
+
// 0.8.7: the copy icon says it worked for a moment (a check, "Copied"), then goes back.
|
|
104
|
+
function copiedMark(btn) {
|
|
105
|
+
clearTimeout(btn._copied)
|
|
106
|
+
btn.classList.add('copied'); btn.innerHTML = icon('check'); btn.setAttribute('aria-label', 'Copied'); btn.title = 'Copied'
|
|
107
|
+
btn._copied = setTimeout(() => { btn.classList.remove('copied'); btn.innerHTML = icon('copy'); btn.setAttribute('aria-label', 'Copy resume command'); btn.title = 'Copy resume command' }, 1500)
|
|
102
108
|
}
|
|
103
109
|
const pill = (status) => '<span class="pill s-' + esc(status) + '">' + esc(LABEL[status] || status) + '</span>'
|
|
104
110
|
|
|
@@ -402,8 +408,44 @@ function agentCard(s) {
|
|
|
402
408
|
agentListChips(s) + (n ? cardBadge(n) : '') + agentCpBlock(s.latestCheckpoint) +
|
|
403
409
|
'<a class="cplink" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session') + ' →</a>' +
|
|
404
410
|
'<div class="actions-row">' + (n ? agentReplyButton(a, 'btn') : '') +
|
|
405
|
-
'
|
|
411
|
+
roNote('read-only · from ' + (a.display || a.name) + '’s feed') + '</div></article>'
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// 0.8.7: a card's actions are one row: its main button (Go, or Resume once it's
|
|
415
|
+
// closed), a small copy icon for the resume command, and "⋯" for the rest. The
|
|
416
|
+
// ⋯ menu's items ride in the card as an inert <template> (real buttons with the
|
|
417
|
+
// same data-action as before), so the one click handler below runs them unchanged.
|
|
418
|
+
function copyResumeIcon(s) {
|
|
419
|
+
return '<button type="button" class="btn icon-btn copy-resume" data-action="resume" data-id="' + esc(s.id) + '" aria-label="Copy resume command" title="Copy resume command">' + icon('copy') + '</button>'
|
|
420
|
+
}
|
|
421
|
+
// items: [{ action, label, icon, title, attrs }]
|
|
422
|
+
function moreMenu(s, items) {
|
|
423
|
+
items = items.filter(Boolean)
|
|
424
|
+
if (!items.length) return ''
|
|
425
|
+
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '" aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
426
|
+
'<template class="more-items">' + items.map((it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
427
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + '>' +
|
|
428
|
+
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>').join('') + '</template>'
|
|
429
|
+
}
|
|
430
|
+
function openCardMenu(btn) {
|
|
431
|
+
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
432
|
+
if (!tpl) return
|
|
433
|
+
const card = btn.closest('.card')
|
|
434
|
+
const title = card && card.querySelector('.title a') ? card.querySelector('.title a').textContent.trim() : ''
|
|
435
|
+
const items = [...tpl.content.querySelectorAll('button')].map((b) => {
|
|
436
|
+
const svg = b.querySelector('svg')
|
|
437
|
+
const attrs = {}
|
|
438
|
+
for (const a of b.attributes) if (a.name.startsWith('data-') || a.name === 'title') attrs[a.name] = a.value
|
|
439
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs }
|
|
440
|
+
})
|
|
441
|
+
openActionMenu(btn, items, { title })
|
|
406
442
|
}
|
|
443
|
+
const sessionMenuItems = (s) => s.isLocal ? [
|
|
444
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus' },
|
|
445
|
+
{ action: 'pin', label: s.pinned ? 'Unpin' : 'Pin', icon: s.pinned ? 'pin-off' : 'pin' },
|
|
446
|
+
{ action: 'archive', label: 'Archive', icon: 'archive' },
|
|
447
|
+
] : []
|
|
448
|
+
const roNote = (t) => '<span class="ro" title="' + esc(t) + '">' + esc(t) + '</span>'
|
|
407
449
|
|
|
408
450
|
function card(s) {
|
|
409
451
|
if (s.agent) return agentCard(s)
|
|
@@ -419,11 +461,9 @@ function card(s) {
|
|
|
419
461
|
(s.needsInput && s.lastNotification && !s.needsYou ? '<span class="chip">' + esc(s.lastNotification.replace(/_/g, ' ')) + '</span>' : '') + '</div>' +
|
|
420
462
|
cardPrsHtml(s.prs) + listChips(s) + cardHandoffChips(s) + nb + cpBlock(s.latestCheckpoint) +
|
|
421
463
|
'<a class="cplink" href="#/s/' + encodeURIComponent(s.id) + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Session history') + ' →</a>' +
|
|
422
|
-
'<div class="actions-row">' + goButton(s) +
|
|
423
|
-
|
|
424
|
-
(s.isLocal ? '
|
|
425
|
-
'<button type="button" class="btn" data-action="pin">' + (s.pinned ? 'Unpin' : 'Pin') + '</button>' +
|
|
426
|
-
'<button type="button" class="btn" data-action="archive">Archive</button>' : '<span class="ro">' + (s.peer && s.peer.ok ? 'lives on ' + esc(s.machine) + ' · answering and Go work through it' : 'read-only here · lives on ' + esc(s.machine)) + '</span>') +
|
|
464
|
+
'<div class="actions-row">' + (goButton(s) || (s.status === 'ended' || s.live === 'closed' ? resumeButton(s, s.viewer || { local: state.viewerLocal !== false, ghostty: !!state.ghostty }) : '')) +
|
|
465
|
+
copyResumeIcon(s) +
|
|
466
|
+
(s.isLocal ? moreMenu(s, sessionMenuItems(s)) : roNote(s.peer && s.peer.ok ? 'lives on ' + s.machine + ' · answering and Go work through it' : 'read-only here · lives on ' + s.machine)) +
|
|
427
467
|
'</div></article>'
|
|
428
468
|
}
|
|
429
469
|
|
|
@@ -669,9 +709,8 @@ function closedCard(s, viewer) {
|
|
|
669
709
|
'<span class="chip machine' + (s.isLocal ? '' : ' remote') + '" title="' + (s.isLocal ? 'this machine' : 'another machine') + '">⌂ ' + esc(s.machine) + '</span>' + modelChip(s) +
|
|
670
710
|
'<span title="' + esc(clock(s.endedAt)) + '">ended ' + esc(ago(s.endedAt)) + '</span><span>·</span><span>' + esc(s.turns) + (s.turns === 1 ? ' turn' : ' turns') + '</span></div>' +
|
|
671
711
|
cardHandoffChips(s) + cpBlock(s.latestCheckpoint, { showWhen: false }) +
|
|
672
|
-
'<div class="actions-row">' + resumeButton(s, viewer) +
|
|
673
|
-
|
|
674
|
-
'<a class="btn" href="' + href + '">' + (s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details') + ' →</a>' +
|
|
712
|
+
'<div class="actions-row">' + resumeButton(s, viewer) + copyResumeIcon(s) +
|
|
713
|
+
moreMenu(s, [{ action: 'open-href', label: s.checkpointCount ? s.checkpointCount + (s.checkpointCount === 1 ? ' checkpoint' : ' checkpoints') : 'Details', icon: 'history', attrs: { 'data-href': href } }]) +
|
|
675
714
|
'</div></article>'
|
|
676
715
|
}
|
|
677
716
|
function closedRepaint() {
|
|
@@ -743,9 +782,8 @@ function parkedCard(s, viewer) {
|
|
|
743
782
|
(s.isLocal ? '' : '<span class="chip machine remote" title="another machine">⌂ ' + esc(s.machine) + '</span>') + modelChip(s) +
|
|
744
783
|
'<span title="' + esc(clock(p.at)) + '">parked ' + esc(ago(p.at)) + '</span>' + (open ? '<span>·</span><span>still open</span>' : '') + '</div>' +
|
|
745
784
|
(note ? '<div class="park-note"><span class="k">' + (p.note ? 'Note' : 'Next') + '</span> ' + esc(note) + '</div>' : '') +
|
|
746
|
-
'<div class="actions-row">' + main +
|
|
747
|
-
(
|
|
748
|
-
'<button type="button" class="linkish park-done" data-action="unpark" data-id="' + esc(s.id) + '" title="No longer parked' + (open ? '' : '; it goes to Closed') + '">Done with it</button>' +
|
|
785
|
+
'<div class="actions-row">' + main + (open ? '' : copyResumeIcon(s)) +
|
|
786
|
+
moreMenu(s, [{ action: 'unpark', label: 'Done with it', icon: 'circle-check', title: 'No longer parked' + (open ? '' : '; it goes to Closed') }]) +
|
|
749
787
|
'</div></article>'
|
|
750
788
|
}
|
|
751
789
|
// The session page's Park row (0.8.2): any open session, Codex too, with an
|
|
@@ -2983,6 +3021,12 @@ document.addEventListener('input', (ev) => {
|
|
|
2983
3021
|
})
|
|
2984
3022
|
document.addEventListener('keydown', (ev) => { if (ev.target.id === 'prs-q' && ev.key === 'Escape' && ev.target.value) { ev.preventDefault(); ev.target.value = ''; ev.target.dispatchEvent(new Event('input', { bubbles: true })) } })
|
|
2985
3023
|
|
|
3024
|
+
window.addEventListener('hashchange', () => closeActionMenu())
|
|
3025
|
+
// 0.8.7: a card's ⋯ opens from the keyboard with the arrows too (Enter and Space click it).
|
|
3026
|
+
document.addEventListener('keydown', (ev) => {
|
|
3027
|
+
const b = ev.target && ev.target.closest && ev.target.closest('[data-action="card-more"]')
|
|
3028
|
+
if (b) actionMenuKey(b, ev, () => openCardMenu(b))
|
|
3029
|
+
})
|
|
2986
3030
|
document.addEventListener('click', async (ev) => {
|
|
2987
3031
|
const btn = ev.target.closest('[data-action]')
|
|
2988
3032
|
if (!btn) return
|
|
@@ -3289,6 +3333,8 @@ document.addEventListener('click', async (ev) => {
|
|
|
3289
3333
|
try { await api('POST', '/v1/needs/restore', action === 'need-restore-all' ? { all: true } : { key: btn.dataset.need }); toast(action === 'need-restore-all' ? 'Restored' : 'Restored to Needs you') } catch (e) { toast(e.message, 5000); btn.disabled = false; return }
|
|
3290
3334
|
return render()
|
|
3291
3335
|
}
|
|
3336
|
+
if (action === 'card-more') { ev.preventDefault(); return openCardMenu(btn) }
|
|
3337
|
+
if (action === 'open-href') { location.hash = btn.dataset.href; return }
|
|
3292
3338
|
if (action === 'go') return goClick(btn, id, s)
|
|
3293
3339
|
if (action === 'resume-open') { const ok = await resumeOpen(btn, btn.dataset.id || id); if (ok && btn.closest('.parked-card')) await render(); return ok }
|
|
3294
3340
|
if (action === 'unpark') {
|
|
@@ -3304,9 +3350,10 @@ document.addEventListener('click', async (ev) => {
|
|
|
3304
3350
|
if (action && action.startsWith('sync-') || action === 'peer-trust') return syncAction(action, btn)
|
|
3305
3351
|
if (action === 'resume') {
|
|
3306
3352
|
const cmd = s ? s.resumeCommand : (await api('GET', '/v1/sessions/' + encodeURIComponent(id))).resumeCommand
|
|
3307
|
-
await copy(cmd)
|
|
3353
|
+
if (await copy(cmd) && btn.classList.contains('copy-resume')) copiedMark(btn)
|
|
3308
3354
|
} else if (action === 'checkpoint') {
|
|
3309
3355
|
btn.disabled = true; btn.textContent = 'Writing…'
|
|
3356
|
+
if (!btn.isConnected) toast('Writing a checkpoint…', 20000) // from the ⋯ menu, which has closed
|
|
3310
3357
|
const cp = await api('POST', '/v1/sessions/' + encodeURIComponent(id) + '/checkpoints', { trigger: 'manual', generate: true })
|
|
3311
3358
|
toast('Checkpoint saved · Next: ' + cp.next)
|
|
3312
3359
|
render()
|
package/ui/icons.js
CHANGED
|
@@ -31,7 +31,14 @@ export const ICONS = {
|
|
|
31
31
|
"chevron-right": "<path d=\"m9 18 6-6-6-6\"/>",
|
|
32
32
|
"chevron-left": "<path d=\"m15 18-6-6 6-6\"/>",
|
|
33
33
|
"mail": "<rect width=\"20\" height=\"16\" x=\"2\" y=\"4\" rx=\"2\"/> <path d=\"m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7\"/>",
|
|
34
|
-
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>"
|
|
34
|
+
"search": "<circle cx=\"11\" cy=\"11\" r=\"8\"/> <path d=\"m21 21-4.3-4.3\"/>",
|
|
35
|
+
"copy": "<rect width=\"14\" height=\"14\" x=\"8\" y=\"8\" rx=\"2\" ry=\"2\"/> <path d=\"M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2\"/>",
|
|
36
|
+
"ellipsis": "<circle cx=\"12\" cy=\"12\" r=\"1\"/> <circle cx=\"19\" cy=\"12\" r=\"1\"/> <circle cx=\"5\" cy=\"12\" r=\"1\"/>",
|
|
37
|
+
"pin": "<path d=\"M12 17v5\"/> <path d=\"M9 10.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1v-.76a2 2 0 0 0-1.11-1.79l-1.78-.9A2 2 0 0 1 15 10.76V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H8a2 2 0 0 0 0 4 1 1 0 0 1 1 1z\"/>",
|
|
38
|
+
"pin-off": "<path d=\"M12 17v5\"/> <path d=\"M15 9.34V7a1 1 0 0 1 1-1 2 2 0 0 0 0-4H7.89\"/> <path d=\"m2 2 20 20\"/> <path d=\"M9 9v1.76a2 2 0 0 1-1.11 1.79l-1.78.9A2 2 0 0 0 5 15.24V16a1 1 0 0 0 1 1h11\"/>",
|
|
39
|
+
"archive": "<rect width=\"20\" height=\"5\" x=\"2\" y=\"3\" rx=\"1\"/> <path d=\"M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8\"/> <path d=\"M10 12h4\"/>",
|
|
40
|
+
"bookmark-plus": "<path d=\"m19 21-7-4-7 4V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2v16z\"/> <path d=\"M12 7v6\"/> <path d=\"M15 10H9\"/>",
|
|
41
|
+
"circle-check": "<circle cx=\"12\" cy=\"12\" r=\"10\"/> <path d=\"m9 12 2 2 4-4\"/>"
|
|
35
42
|
}
|
|
36
43
|
|
|
37
44
|
export function icon(name, cls = 'ic') {
|
package/ui/picker.js
CHANGED
|
@@ -276,13 +276,117 @@ function onKey(host, e) {
|
|
|
276
276
|
}
|
|
277
277
|
}
|
|
278
278
|
|
|
279
|
+
|
|
280
|
+
// 0.8.7: a card's "⋯" menu. Not a select: a list of actions (role menu, items
|
|
281
|
+
// role menuitem). The items are real <button data-action …> elements, so the
|
|
282
|
+
// board's one click handler runs them exactly as it ran the old row of buttons;
|
|
283
|
+
// choosing one closes the menu. Same look as the selects' menu: under (or
|
|
284
|
+
// above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
|
|
285
|
+
// Enter/Space, Escape (focus back on the ⋯), Tab, and a tap outside closes.
|
|
286
|
+
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls }]
|
|
287
|
+
let act = null // { anchor, menu, backdrop, items }
|
|
288
|
+
export function actionMenuOpen() { return !!act }
|
|
289
|
+
export function closeActionMenu({ focus = false } = {}) {
|
|
290
|
+
if (!act) return
|
|
291
|
+
const { anchor, menu, backdrop } = act
|
|
292
|
+
act = null
|
|
293
|
+
menu.remove(); if (backdrop) backdrop.remove()
|
|
294
|
+
anchor.setAttribute('aria-expanded', 'false')
|
|
295
|
+
if (focus && anchor.isConnected) anchor.focus()
|
|
296
|
+
}
|
|
297
|
+
function placeAction() {
|
|
298
|
+
if (!act) return
|
|
299
|
+
const { menu, anchor } = act
|
|
300
|
+
const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
|
|
301
|
+
if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
|
|
302
|
+
const r = anchor.getBoundingClientRect()
|
|
303
|
+
const pad = 8
|
|
304
|
+
const w = menu.offsetWidth, h = menu.offsetHeight
|
|
305
|
+
const up = vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
306
|
+
// right edges line up (the ⋯ sits at the end of the row), never past the viewport
|
|
307
|
+
menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
|
|
308
|
+
menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : r.bottom + 6) + 'px'
|
|
309
|
+
menu.classList.toggle('up', up)
|
|
310
|
+
}
|
|
311
|
+
function actFocus(k) {
|
|
312
|
+
if (!act) return
|
|
313
|
+
const n = act.items.length
|
|
314
|
+
if (!n) return
|
|
315
|
+
act.items[((k % n) + n) % n].focus()
|
|
316
|
+
}
|
|
317
|
+
export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
318
|
+
if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
|
|
319
|
+
closeActionMenu(); closeMenu()
|
|
320
|
+
const sheet = narrow()
|
|
321
|
+
const id = 'lo-act' + (++uid)
|
|
322
|
+
const menu = document.createElement('div')
|
|
323
|
+
menu.className = 'lo-menu lo-actmenu' + (sheet ? ' sheet' : '')
|
|
324
|
+
menu.id = id
|
|
325
|
+
menu.setAttribute('role', 'menu')
|
|
326
|
+
if (title) menu.setAttribute('aria-label', title)
|
|
327
|
+
menu.innerHTML = (sheet && title ? '<div class="lo-sheet-head"><span>' + esc(title) + '</span><button type="button" class="lo-sheet-done" data-act-close>Done</button></div>' : '') +
|
|
328
|
+
items.map((it) => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') + '<span>' + esc(it.label) + '</span></button>').join('')
|
|
329
|
+
let backdrop = null
|
|
330
|
+
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
331
|
+
document.body.appendChild(menu)
|
|
332
|
+
act = { anchor, menu, backdrop, items: [...menu.querySelectorAll('[role="menuitem"]')] }
|
|
333
|
+
anchor.setAttribute('aria-haspopup', 'menu')
|
|
334
|
+
anchor.setAttribute('aria-controls', id)
|
|
335
|
+
anchor.setAttribute('aria-expanded', 'true')
|
|
336
|
+
menu.addEventListener('click', (e) => {
|
|
337
|
+
if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
|
|
338
|
+
// the item's own click goes on to the board's handler (the event's path is
|
|
339
|
+
// already fixed); the menu just gets out of the way
|
|
340
|
+
if (e.target.closest('[role="menuitem"]')) closeActionMenu()
|
|
341
|
+
})
|
|
342
|
+
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
|
|
343
|
+
menu.addEventListener('keydown', (e) => {
|
|
344
|
+
const k = act ? act.items.indexOf(document.activeElement) : -1
|
|
345
|
+
switch (e.key) {
|
|
346
|
+
case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
|
|
347
|
+
case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
|
|
348
|
+
case 'Home': e.preventDefault(); actFocus(0); return
|
|
349
|
+
case 'End': e.preventDefault(); actFocus(-1); return
|
|
350
|
+
case 'Escape': e.preventDefault(); e.stopPropagation(); closeActionMenu({ focus: true }); return
|
|
351
|
+
case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
|
|
352
|
+
default:
|
|
353
|
+
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
|
|
354
|
+
const ch = e.key.toLowerCase()
|
|
355
|
+
const j = act.items.findIndex((x, i) => i > k && x.textContent.trim().toLowerCase().startsWith(ch))
|
|
356
|
+
const j2 = j >= 0 ? j : act.items.findIndex((x) => x.textContent.trim().toLowerCase().startsWith(ch))
|
|
357
|
+
if (j2 >= 0) { e.preventDefault(); actFocus(j2) }
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
})
|
|
361
|
+
placeAction()
|
|
362
|
+
actFocus(0)
|
|
363
|
+
return menu
|
|
364
|
+
}
|
|
365
|
+
// the ⋯ button itself: ArrowDown/Up opens the menu too (Enter and Space click it)
|
|
366
|
+
export function actionMenuKey(anchor, e, open) {
|
|
367
|
+
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') { e.preventDefault(); open(); if (act && e.key === 'ArrowUp') actFocus(-1) }
|
|
368
|
+
}
|
|
369
|
+
|
|
279
370
|
if (typeof window !== 'undefined' && window.customElements && !customElements.get('lo-select')) {
|
|
280
371
|
customElements.define('lo-select', LoSelect)
|
|
281
372
|
customElements.define('lo-combo', LoCombo)
|
|
282
373
|
// a click outside (or a tap on the page behind a sheet) closes; so does the page moving under a desktop menu
|
|
283
374
|
document.addEventListener('pointerdown', (e) => { if (open && !open.menu.contains(e.target) && !open.host.contains(e.target)) closeMenu() }, true)
|
|
284
|
-
window.addEventListener('resize', () => { if (open) closeMenu() })
|
|
285
|
-
document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place() }, true)
|
|
375
|
+
window.addEventListener('resize', () => { if (open) closeMenu(); if (act) closeActionMenu() })
|
|
376
|
+
document.addEventListener('scroll', (e) => { if (open && !open.menu.contains(e.target)) place(); if (act && !act.menu.contains(e.target)) placeAction() }, true)
|
|
377
|
+
// a tap outside a card's ⋯ menu closes it (and goes nowhere: a tap on the backdrop, or anywhere on a desktop page, only closes)
|
|
378
|
+
document.addEventListener('pointerdown', (e) => {
|
|
379
|
+
if (!act || act.menu.contains(e.target) || act.anchor.contains(e.target)) return
|
|
380
|
+
closeActionMenu()
|
|
381
|
+
// the click that ends this same tap is eaten; a later tap elsewhere is not
|
|
382
|
+
const x = e.clientX, y = e.clientY, at = Date.now()
|
|
383
|
+
const swallow = (ev) => {
|
|
384
|
+
document.removeEventListener('click', swallow, true)
|
|
385
|
+
if (Date.now() - at < 600 && Math.abs(ev.clientX - x) < 12 && Math.abs(ev.clientY - y) < 12) { ev.preventDefault(); ev.stopPropagation() }
|
|
386
|
+
}
|
|
387
|
+
document.addEventListener('click', swallow, true)
|
|
388
|
+
setTimeout(() => document.removeEventListener('click', swallow, true), 600)
|
|
389
|
+
}, true)
|
|
286
390
|
// <label for="f-tz"> focuses (and on a click, opens) the control, as it would a select
|
|
287
391
|
document.addEventListener('click', (e) => {
|
|
288
392
|
const l = e.target.closest && e.target.closest('label[for]')
|