@leftoff/cli 0.9.3 → 0.9.4
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 +34 -5
- package/ui/app.js +13 -10
- package/ui/picker.js +84 -18
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.9.4] - 2026-10-14
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
- The card's **⋯** menu has one **Checkpoint now** row again. Tap it for your default length, or tap **›** at its end to pick Short, Informative or Long.
|
|
9
|
+
|
|
5
10
|
## [0.9.3] - 2026-10-14
|
|
6
11
|
|
|
7
12
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "leftoff",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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.9.
|
|
31
|
+
const MOD_VERSION = '0.9.4' // 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.9.
|
|
3
|
+
"version": "0.9.4",
|
|
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
|
@@ -1784,13 +1784,42 @@ html.dv-open .lo-menu { z-index: 62; }
|
|
|
1784
1784
|
.lo-act-sub { font-size: 12.5px; color: var(--muted); font-weight: 400; line-height: 1.35; }
|
|
1785
1785
|
.lo-act-note { display: flex; align-items: flex-start; gap: 10px; padding: 8px 10px 10px; margin-bottom: 4px; border-bottom: 1px solid var(--line); color: var(--text2); font-size: 13.5px; line-height: 1.4; white-space: normal; }
|
|
1786
1786
|
.lo-act-note .ic { width: 16px; height: 16px; flex: none; margin-top: 2px; color: var(--muted); }
|
|
1787
|
-
.lo-act-row { display: flex; align-items: center; gap: 6px; padding: 2px 10px 6px 36px; }
|
|
1788
|
-
.lo-act-rowlabel { font-size: 12.5px; color: var(--muted); margin-right: 2px; }
|
|
1789
|
-
.lo-act.alt { width: auto; flex: 0 0 auto; padding: 5px 12px; border: 1px solid var(--line-strong); border-radius: 999px; font-size: 13px; color: var(--text2); }
|
|
1790
1787
|
.lo-menu.sheet .lo-act-note { font-size: 15px; padding: 10px 10px 12px; }
|
|
1791
1788
|
.lo-menu.sheet .lo-act-sub { font-size: 14px; }
|
|
1792
|
-
.
|
|
1793
|
-
|
|
1789
|
+
/* 0.9.4: a row with a second page (Checkpoint now ›), the iOS way: the row
|
|
1790
|
+
does its action, the › at its right edge (its own target, a hairline before
|
|
1791
|
+
it) slides the menu to the page; ‹ title goes back. Fixed width on a
|
|
1792
|
+
desktop so the page change only eases the height. */
|
|
1793
|
+
.lo-actmenu.has-pages { overflow-x: hidden; }
|
|
1794
|
+
.lo-menu.lo-actmenu.has-pages:not(.sheet) { width: min(312px, calc(100vw - 16px)); max-width: none; }
|
|
1795
|
+
.lo-act-split { display: flex; align-items: stretch; position: relative; }
|
|
1796
|
+
.lo-act-split .lo-act-main { flex: 1 1 auto; min-width: 0; }
|
|
1797
|
+
.lo-act-next { flex: none; display: flex; align-items: center; justify-content: center; width: 40px; margin-left: 2px; padding: 0; border: 0; border-radius: 8px; background: none; color: var(--muted); cursor: pointer; position: relative; }
|
|
1798
|
+
.lo-act-next::before { content: ""; position: absolute; left: -1px; top: 9px; bottom: 9px; width: 1px; background: var(--line); }
|
|
1799
|
+
.lo-act-next .ic { width: 16px; height: 16px; }
|
|
1800
|
+
.lo-act-next:hover, .lo-act-next:focus { outline: none; background: var(--bg2); color: var(--text); }
|
|
1801
|
+
[data-theme="dark"] .lo-act-next:hover, [data-theme="dark"] .lo-act-next:focus, :root:not([data-theme="light"]) .lo-act-next:hover, :root:not([data-theme="light"]) .lo-act-next:focus { background: var(--line); }
|
|
1802
|
+
.lo-act-next:focus-visible { box-shadow: inset 0 0 0 2px var(--focus); }
|
|
1803
|
+
.lo-act-split:hover .lo-act-next::before, .lo-act-split:focus-within .lo-act-next::before { opacity: 0; }
|
|
1804
|
+
.lo-act-back { color: var(--text); font-weight: 600; border-bottom: 1px solid var(--line); border-radius: 8px 8px 0 0; margin-bottom: 4px; }
|
|
1805
|
+
.lo-act-back .ic { color: var(--accent); }
|
|
1806
|
+
.lo-act-page .lo-act.has-sub { padding-top: 9px; padding-bottom: 9px; }
|
|
1807
|
+
.lo-act-page .lo-act.on { font-weight: 600; }
|
|
1808
|
+
.lo-act .lo-act-check { color: var(--accent); }
|
|
1809
|
+
.lo-act .lo-act-sub { white-space: normal; }
|
|
1810
|
+
.lo-vh { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); clip-path: inset(50%); white-space: nowrap; }
|
|
1811
|
+
.lo-menu.lo-act-sizing { transition: height .2s cubic-bezier(.2, .8, .2, 1), top .2s cubic-bezier(.2, .8, .2, 1); overflow-y: hidden; }
|
|
1812
|
+
@keyframes lo-page-fwd { from { opacity: 0; transform: translateX(18px); } to { opacity: 1; transform: none; } }
|
|
1813
|
+
@keyframes lo-page-back { from { opacity: 0; transform: translateX(-18px); } to { opacity: 1; transform: none; } }
|
|
1814
|
+
.lo-act-page.lo-in-fwd { animation: lo-page-fwd .2s cubic-bezier(.2, .8, .2, 1); }
|
|
1815
|
+
.lo-act-page.lo-in-back { animation: lo-page-back .2s cubic-bezier(.2, .8, .2, 1); }
|
|
1816
|
+
.lo-menu.sheet .lo-act-next { width: 52px; min-height: 44px; }
|
|
1817
|
+
.lo-menu.sheet .lo-act-next .ic { width: 20px; height: 20px; }
|
|
1818
|
+
.lo-menu.sheet .lo-act-next::before { top: 12px; bottom: 12px; }
|
|
1819
|
+
.lo-menu.sheet .lo-act-page .lo-act.has-sub { padding-top: 12px; padding-bottom: 12px; }
|
|
1820
|
+
/* a phone keeps :hover on the last row tapped; only focus highlights there */
|
|
1821
|
+
@media (hover: none) { :root .lo-act:hover:not(:focus), :root .lo-act-next:hover:not(:focus) { background: none; } }
|
|
1822
|
+
@media (prefers-reduced-motion: reduce) { .lo-act-page.lo-in-fwd, .lo-act-page.lo-in-back { animation: none; } .lo-menu.lo-act-sizing { transition: none; } }
|
|
1794
1823
|
.split { display: inline-flex; align-items: stretch; }
|
|
1795
1824
|
.split .btn.split-main { border-top-right-radius: 0; border-bottom-right-radius: 0; }
|
|
1796
1825
|
.split .btn.split-more { border-top-left-radius: 0; border-bottom-left-radius: 0; padding: 0 8px; margin-left: 1px; display: inline-flex; align-items: center; }
|
package/ui/app.js
CHANGED
|
@@ -420,17 +420,19 @@ function copyResumeIcon(s) {
|
|
|
420
420
|
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>'
|
|
421
421
|
}
|
|
422
422
|
// items: [{ action, label, icon, title, attrs }]
|
|
423
|
-
// 0.9.2: an item may also be { note } (a plain line, no button)
|
|
424
|
-
//
|
|
423
|
+
// 0.9.2: an item may also be { note } (a plain line, no button) and carry a sub line.
|
|
424
|
+
// 0.9.4: or { …item, more: { title, items } }: the row does its action, and a ›
|
|
425
|
+
// at its right edge opens a second page of items (e.g. the checkpoint lengths,
|
|
426
|
+
// each with { sub, on }).
|
|
425
427
|
function moreMenu(s, items, { project = true, title = '' } = {}) {
|
|
426
428
|
items = items.concat([project ? projectMenuItem(s) : null]).filter(Boolean) // 0.9.0: Add to project… on every card with a repo
|
|
427
429
|
if (!items.length) return ''
|
|
428
430
|
const btn = (it) => '<button type="button" data-action="' + esc(it.action) + '" data-id="' + esc(s.id) + '"' +
|
|
429
|
-
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + '>' +
|
|
431
|
+
Object.entries(it.attrs || {}).map(([k, v]) => ' ' + k + '="' + esc(v) + '"').join('') + (it.title ? ' title="' + esc(it.title) + '"' : '') + (it.sub ? ' data-sub="' + esc(it.sub) + '"' : '') + (it.on ? ' data-on="' + esc(it.onLabel || 'on') + '"' : '') + '>' +
|
|
430
432
|
(it.icon ? icon(it.icon) : '') + esc(it.label) + '</button>'
|
|
431
433
|
return '<button type="button" class="btn icon-btn more" data-action="card-more" data-id="' + esc(s.id) + '"' + (title ? ' data-menu-title="' + esc(title) + '"' : '') + ' aria-label="More actions" title="More actions" aria-haspopup="menu" aria-expanded="false">' + icon('ellipsis') + '</button>' +
|
|
432
434
|
'<template class="more-items">' + items.map((it) => it.note ? '<div class="more-note">' + (it.icon ? icon(it.icon) : '') + esc(it.note) + '</div>'
|
|
433
|
-
: it.
|
|
435
|
+
: it.more ? '<div class="more-split" data-more-title="' + esc(it.more.title) + '">' + btn(it) + '<div class="more-sub">' + it.more.items.map(btn).join('') + '</div></div>' : btn(it)).join('') + '</template>'
|
|
434
436
|
}
|
|
435
437
|
function openCardMenu(btn) {
|
|
436
438
|
const tpl = btn.parentElement && btn.parentElement.querySelector('template.more-items')
|
|
@@ -440,12 +442,12 @@ function openCardMenu(btn) {
|
|
|
440
442
|
const toItem = (b) => {
|
|
441
443
|
const svg = b.querySelector('svg')
|
|
442
444
|
const attrs = {}
|
|
443
|
-
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub') || a.name === 'title') attrs[a.name] = a.value
|
|
444
|
-
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}) }
|
|
445
|
+
for (const a of b.attributes) if ((a.name.startsWith('data-') && a.name !== 'data-sub' && a.name !== 'data-on') || a.name === 'title') attrs[a.name] = a.value
|
|
446
|
+
return { label: b.textContent.trim(), icon: svg ? svg.outerHTML : '', attrs, ...(b.dataset.sub ? { sub: b.dataset.sub } : {}), ...(b.dataset.on ? { on: true, onLabel: b.dataset.on } : {}) }
|
|
445
447
|
}
|
|
446
448
|
const items = [...tpl.content.children].map((el) => {
|
|
447
449
|
if (el.classList.contains('more-note')) { const svg = el.querySelector('svg'); return { note: el.textContent.trim(), icon: svg ? svg.outerHTML : '' } }
|
|
448
|
-
if (el.classList.contains('more-
|
|
450
|
+
if (el.classList.contains('more-split')) return { ...toItem(el.querySelector(':scope > button')), more: { title: el.dataset.moreTitle || '', items: [...el.querySelectorAll('.more-sub > button')].map(toItem) } }
|
|
449
451
|
return el.tagName === 'BUTTON' ? toItem(el) : null
|
|
450
452
|
}).filter(Boolean)
|
|
451
453
|
lastMenuAnchor = btn
|
|
@@ -463,12 +465,13 @@ function reachOf(s) {
|
|
|
463
465
|
// The length "Checkpoint now" writes: yours from Settings → Summaries.
|
|
464
466
|
function defaultLength() { const k = state.cfg && state.cfg.summary && state.cfg.summary.length; return LENGTH_CHOICES.some((o) => o.key === k) ? k : 'informative' }
|
|
465
467
|
const lengthLabel = (k) => (LENGTH_CHOICES.find((o) => o.key === k) || LENGTH_CHOICES[1]).label
|
|
466
|
-
// Checkpoint now at your length
|
|
468
|
+
// 0.9.4: one row, "Checkpoint now" at your length; its › opens Short /
|
|
469
|
+
// Informative / Long, each with its line, your default ticked.
|
|
467
470
|
function checkpointItems() {
|
|
468
471
|
const def = defaultLength()
|
|
469
472
|
return [
|
|
470
|
-
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus',
|
|
471
|
-
|
|
473
|
+
{ action: 'checkpoint', label: 'Checkpoint now', icon: 'bookmark-plus', title: 'Checkpoint now · ' + lengthLabel(def), attrs: { 'data-length': def },
|
|
474
|
+
more: { title: 'Checkpoint length', items: LENGTH_CHOICES.map((o) => ({ action: 'checkpoint', label: o.label, sub: o.blurb, on: o.key === def, onLabel: 'your default', attrs: { 'data-length': o.key } })) } },
|
|
472
475
|
]
|
|
473
476
|
}
|
|
474
477
|
// The ⋯ menu when the Mac a session lives on can't be reached: why, in plain
|
package/ui/picker.js
CHANGED
|
@@ -283,8 +283,9 @@ function onKey(host, e) {
|
|
|
283
283
|
// choosing one closes the menu. Same look as the selects' menu: under (or
|
|
284
284
|
// above) the ⋯ on a desktop, a bottom sheet under 600px. Arrows, Home/End,
|
|
285
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
|
-
|
|
286
|
+
// items: [{ label, icon (svg markup, optional), attrs: { 'data-action': …, 'data-id': … }, cls, sub, on }]
|
|
287
|
+
// plus { note } (a plain line) and { …item, more: { title, items } } (a › to a second page)
|
|
288
|
+
let act = null // { anchor, menu, backdrop, items, page, up }
|
|
288
289
|
export function actionMenuOpen() { return !!act }
|
|
289
290
|
export function closeActionMenu({ focus = false } = {}) {
|
|
290
291
|
if (!act) return
|
|
@@ -294,18 +295,22 @@ export function closeActionMenu({ focus = false } = {}) {
|
|
|
294
295
|
anchor.setAttribute('aria-expanded', 'false')
|
|
295
296
|
if (focus && anchor.isConnected) anchor.focus()
|
|
296
297
|
}
|
|
297
|
-
|
|
298
|
+
// h: the height to place for (a page change animates to it); keep: stay on the
|
|
299
|
+
// side of the ⋯ the menu opened on, so a second page doesn't make it jump.
|
|
300
|
+
function placeAction({ h: hTo, keep = false } = {}) {
|
|
298
301
|
if (!act) return
|
|
299
302
|
const { menu, anchor } = act
|
|
300
303
|
const vw = document.documentElement.clientWidth, vh = (window.visualViewport && window.visualViewport.height) || window.innerHeight
|
|
301
304
|
if (menu.classList.contains('sheet')) { menu.style.maxHeight = Math.round(vh * 0.72) + 'px'; return }
|
|
302
305
|
const r = anchor.getBoundingClientRect()
|
|
303
306
|
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
|
|
307
|
+
const w = menu.offsetWidth, h = hTo || menu.offsetHeight
|
|
308
|
+
const up = keep && act.up != null ? act.up : vh - r.bottom - pad < h + 6 && r.top - pad > vh - r.bottom - pad
|
|
309
|
+
act.up = up
|
|
306
310
|
// right edges line up (the ⋯ sits at the end of the row), never past the viewport
|
|
307
311
|
menu.style.left = Math.max(pad, Math.min(r.right - w, vw - pad - w)) + 'px'
|
|
308
|
-
|
|
312
|
+
// never past the bottom either (a taller second page can't fall off the screen)
|
|
313
|
+
menu.style.top = (up ? Math.max(pad, r.top - 6 - h) : Math.max(pad, Math.min(r.bottom + 6, vh - pad - h))) + 'px'
|
|
309
314
|
menu.classList.toggle('up', up)
|
|
310
315
|
}
|
|
311
316
|
function actFocus(k) {
|
|
@@ -314,6 +319,44 @@ function actFocus(k) {
|
|
|
314
319
|
if (!n) return
|
|
315
320
|
act.items[((k % n) + n) % n].focus()
|
|
316
321
|
}
|
|
322
|
+
const SVG = (d, cls = 'ic') => '<svg class="' + cls + '" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">' + d + '</svg>'
|
|
323
|
+
const ACT_NEXT = SVG('<path d="m9 18 6-6-6-6"/>', 'ic lo-act-chev')
|
|
324
|
+
const ACT_BACK = SVG('<path d="m15 18-6-6 6-6"/>', 'ic lo-act-chev')
|
|
325
|
+
const ACT_CHECK = SVG('<path d="M20 6 9 17l-5-5"/>', 'ic lo-act-check')
|
|
326
|
+
const reducedMotion = () => !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches)
|
|
327
|
+
// 0.9.4: show page p of the menu (0 is the main one). The new page slides in
|
|
328
|
+
// from the side it lives on and the menu's height eases to the new one (its
|
|
329
|
+
// width is fixed), unless you asked for less motion.
|
|
330
|
+
function actPage(p, { focus } = {}) {
|
|
331
|
+
if (!act) return
|
|
332
|
+
const { menu } = act
|
|
333
|
+
const to = menu.querySelector('.lo-act-page[data-page="' + p + '"]'), from = act.page
|
|
334
|
+
if (!to || !from || to === from) return
|
|
335
|
+
const back = p < Number(from.dataset.page)
|
|
336
|
+
const h0 = menu.offsetHeight
|
|
337
|
+
from.hidden = true; to.hidden = false
|
|
338
|
+
from.classList.remove('lo-in-fwd', 'lo-in-back')
|
|
339
|
+
act.page = to
|
|
340
|
+
act.items = [...to.querySelectorAll('[role="menuitem"]')]
|
|
341
|
+
menu.classList.toggle('on-sub', p > 0)
|
|
342
|
+
const h1 = menu.offsetHeight
|
|
343
|
+
if (!reducedMotion()) {
|
|
344
|
+
to.classList.remove('lo-in-fwd', 'lo-in-back'); void to.offsetWidth
|
|
345
|
+
to.classList.add(back ? 'lo-in-back' : 'lo-in-fwd')
|
|
346
|
+
if (h1 !== h0) {
|
|
347
|
+
menu.classList.remove('lo-act-sizing')
|
|
348
|
+
menu.style.height = h0 + 'px'; void menu.offsetHeight
|
|
349
|
+
menu.classList.add('lo-act-sizing')
|
|
350
|
+
menu.style.height = h1 + 'px'
|
|
351
|
+
let t = 0
|
|
352
|
+
const done = (e) => { if (e && e.target !== menu) return; clearTimeout(t); menu.removeEventListener('transitionend', done); if (act && act.menu === menu) { menu.style.height = ''; menu.classList.remove('lo-act-sizing') } }
|
|
353
|
+
menu.addEventListener('transitionend', done); t = setTimeout(done, 320)
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
placeAction({ h: h1, keep: true })
|
|
357
|
+
const el = focus || (p > 0 ? (to.querySelector('.lo-act.on') || act.items[1] || act.items[0]) : act.items[0])
|
|
358
|
+
if (el) el.focus({ preventScroll: true })
|
|
359
|
+
}
|
|
317
360
|
export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
318
361
|
if (act && act.anchor === anchor) { closeActionMenu({ focus: true }); return null }
|
|
319
362
|
closeActionMenu(); closeMenu()
|
|
@@ -324,39 +367,62 @@ export function openActionMenu(anchor, items, { title = '' } = {}) {
|
|
|
324
367
|
menu.id = id
|
|
325
368
|
menu.setAttribute('role', 'menu')
|
|
326
369
|
if (title) menu.setAttribute('aria-label', title)
|
|
327
|
-
// 0.9.2: an item may carry a sub line ({ sub })
|
|
328
|
-
// what the menu is about, read out as its description)
|
|
329
|
-
//
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
370
|
+
// 0.9.2: an item may carry a sub line ({ sub }) or be a plain line ({ note }:
|
|
371
|
+
// what the menu is about, read out as its description).
|
|
372
|
+
// 0.9.4: an item may open a second page ({ more: { title, items } }), the iOS
|
|
373
|
+
// way: the row itself does its action, and a › at its right edge (its own
|
|
374
|
+
// target) slides the menu over to that page, which starts with a ‹ title row
|
|
375
|
+
// (back). Items there may be { on }: a ✓, e.g. your default length.
|
|
376
|
+
const one = (it, extra = '') => '<button type="button" role="menuitem" tabindex="-1" class="lo-act' + extra + (it.sub ? ' has-sub' : '') + (it.on ? ' on' : '') + (it.cls ? ' ' + esc(it.cls) : '') + '"' + attrs(it.attrs || {}) + '>' + (it.icon || '') +
|
|
377
|
+
(it.sub ? '<span class="lo-act-txt"><span>' + esc(it.label) + '</span><span class="lo-act-sub">' + esc(it.sub) + '</span></span>' : '<span>' + esc(it.label) + '</span>') +
|
|
378
|
+
(it.on && it.onLabel ? '<span class="lo-vh">' + esc(it.onLabel) + '</span>' : '') + '</button>'
|
|
379
|
+
const notes = [], subs = []
|
|
380
|
+
const split = (it) => { subs.push(it.more); const p = subs.length
|
|
381
|
+
return '<div class="lo-act-split" role="none">' + one(it, ' lo-act-main') +
|
|
382
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act-next" data-act-page="' + p + '" aria-haspopup="menu" aria-label="' + esc(it.more.title) + '" title="' + esc(it.more.title) + '">' + ACT_NEXT + '</button></div>' }
|
|
383
|
+
const main = items.map((it, i) => it.note ? (notes.push(id + '-n' + i), '<div class="lo-act-note" id="' + id + '-n' + i + '" role="none">' + (it.icon || '') + '<span>' + esc(it.note) + '</span></div>')
|
|
384
|
+
: it.more ? split(it) : one(it)).join('')
|
|
385
|
+
const page = (m, p) => '<div class="lo-act-page" data-page="' + p + '" role="group" aria-label="' + esc(m.title) + '" hidden>' +
|
|
386
|
+
'<button type="button" role="menuitem" tabindex="-1" class="lo-act lo-act-back" data-act-back aria-label="' + esc('Back · ' + m.title) + '">' + ACT_BACK + '<span>' + esc(m.title) + '</span></button>' +
|
|
387
|
+
(m.items || []).map((x) => one({ ...x, icon: x.on ? ACT_CHECK : '<span class="ic" aria-hidden="true"></span>' })).join('') + '</div>'
|
|
333
388
|
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>' : '') +
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
: one(it)).join('')
|
|
389
|
+
(subs.length ? '<div class="lo-act-page" data-page="0" role="none">' + main + '</div>' + subs.map((m, j) => page(m, j + 1)).join('') : main)
|
|
390
|
+
if (subs.length) menu.classList.add('has-pages')
|
|
337
391
|
if (notes.length) menu.setAttribute('aria-describedby', notes.join(' '))
|
|
338
392
|
let backdrop = null
|
|
339
393
|
if (sheet) { backdrop = document.createElement('div'); backdrop.className = 'lo-backdrop'; document.body.appendChild(backdrop) }
|
|
340
394
|
document.body.appendChild(menu)
|
|
341
|
-
|
|
395
|
+
const page0 = menu.querySelector('.lo-act-page[data-page="0"]')
|
|
396
|
+
act = { anchor, menu, backdrop, page: page0, up: null, items: [...(page0 || menu).querySelectorAll('[role="menuitem"]')] }
|
|
342
397
|
anchor.setAttribute('aria-haspopup', 'menu')
|
|
343
398
|
anchor.setAttribute('aria-controls', id)
|
|
344
399
|
anchor.setAttribute('aria-expanded', 'true')
|
|
400
|
+
const backFocus = () => act && act.menu.querySelector('[data-act-page="' + act.page.dataset.page + '"]')
|
|
345
401
|
menu.addEventListener('click', (e) => {
|
|
346
402
|
if (e.target.closest('[data-act-close]')) { closeActionMenu({ focus: true }); return }
|
|
403
|
+
const next = e.target.closest('[data-act-page]')
|
|
404
|
+
if (next) { e.preventDefault(); actPage(Number(next.dataset.actPage)); return }
|
|
405
|
+
if (e.target.closest('[data-act-back]')) { e.preventDefault(); actPage(0, { focus: backFocus() }); return }
|
|
347
406
|
// the item's own click goes on to the board's handler (the event's path is
|
|
348
407
|
// already fixed); the menu just gets out of the way
|
|
349
408
|
if (e.target.closest('[role="menuitem"]')) closeActionMenu()
|
|
350
409
|
})
|
|
351
|
-
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it) it.focus({ preventScroll: true }) })
|
|
410
|
+
menu.addEventListener('mousemove', (e) => { const it = e.target.closest('[role="menuitem"]'); if (it && document.activeElement !== it && act && act.page && act.page.contains(it)) it.focus({ preventScroll: true }) })
|
|
352
411
|
menu.addEventListener('keydown', (e) => {
|
|
353
412
|
const k = act ? act.items.indexOf(document.activeElement) : -1
|
|
413
|
+
const onSub = !!(act && act.page && Number(act.page.dataset.page) > 0)
|
|
354
414
|
switch (e.key) {
|
|
355
415
|
case 'ArrowDown': e.preventDefault(); actFocus(k + 1); return
|
|
356
416
|
case 'ArrowUp': e.preventDefault(); actFocus(k < 0 ? -1 : k - 1); return
|
|
357
417
|
case 'Home': e.preventDefault(); actFocus(0); return
|
|
358
418
|
case 'End': e.preventDefault(); actFocus(-1); return
|
|
359
|
-
case '
|
|
419
|
+
case 'ArrowRight': {
|
|
420
|
+
const row = document.activeElement && document.activeElement.closest('.lo-act-split')
|
|
421
|
+
if (row) { e.preventDefault(); actPage(Number(row.querySelector('[data-act-page]').dataset.actPage)) }
|
|
422
|
+
return
|
|
423
|
+
}
|
|
424
|
+
case 'ArrowLeft': if (onSub) { e.preventDefault(); actPage(0, { focus: backFocus() }) } return
|
|
425
|
+
case 'Escape': e.preventDefault(); e.stopPropagation(); if (onSub) actPage(0, { focus: backFocus() }); else closeActionMenu({ focus: true }); return
|
|
360
426
|
case 'Tab': e.preventDefault(); closeActionMenu({ focus: true }); return
|
|
361
427
|
default:
|
|
362
428
|
if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey && act) {
|