feat(webui): make Overview board rows clickable end to end
Each action-board row on /apps/overview already carries a navigation button (Review / View / Open Backups). The whole row now triggers that same navigation, so the small button is no longer the only hit target. Rows opt in via a `nav` descriptor, so only plain go-to-that-tab rows become clickable — rows with no action, and the "Update all" button inside the updates row, are unaffected (updater actions are matched first in the delegated handler, so Update all never double-fires). The row is not given role="button": nesting the real buttons inside a button role would break them for assistive tech. The inner button stays the focusable, keyboard-reachable control; the row is a mouse-only widening, with a hover state that lifts row and button together. A whole-row click is suppressed when it ends a text selection. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
c9a513abc0
commit
cbbb978e50
@ -117,6 +117,22 @@
|
|||||||
background: rgba(var(--text-rgb), .03);
|
background: rgba(var(--text-rgb), .03);
|
||||||
border-color: rgba(var(--text-rgb), .08);
|
border-color: rgba(var(--text-rgb), .08);
|
||||||
}
|
}
|
||||||
|
/* Rows whose action is just "go to that tab" are clickable end to end — the
|
||||||
|
button inside remains the affordance (and the focusable control), the row
|
||||||
|
only widens the target. Hover lifts the whole row AND the button together so
|
||||||
|
it reads as one control, not a row that happens to sit under the cursor. */
|
||||||
|
.ov-board-row.ov-clickable { cursor: pointer; transition: background .12s ease, border-color .12s ease; }
|
||||||
|
.ov-board-row.ov-clickable:hover {
|
||||||
|
background: rgba(var(--page-rgb, var(--accent-rgb)), .11);
|
||||||
|
border-color: rgba(var(--page-rgb, var(--accent-rgb)), .28);
|
||||||
|
}
|
||||||
|
.ov-board-row.ov-clickable.ok:hover,
|
||||||
|
.ov-board-row.ov-clickable.none:hover {
|
||||||
|
background: rgba(var(--text-rgb), .06);
|
||||||
|
border-color: rgba(var(--text-rgb), .14);
|
||||||
|
}
|
||||||
|
/* Keep the button's own hover styling from reading as "only this bit is live". */
|
||||||
|
.ov-board-row.ov-clickable:hover .ov-board-actions .updater-btn { filter: brightness(1.08); }
|
||||||
.ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
|
.ov-dot { width: 9px; height: 9px; border-radius: 50%; flex: 0 0 auto; }
|
||||||
.ov-dot.ok { background: #36d399; }
|
.ov-dot.ok { background: #36d399; }
|
||||||
.ov-dot.warn { background: #fbbd23; }
|
.ov-dot.warn { background: #fbbd23; }
|
||||||
|
|||||||
@ -316,6 +316,13 @@ class OverviewManager {
|
|||||||
if (oa) {
|
if (oa) {
|
||||||
switch (oa.dataset.overviewAction) {
|
switch (oa.dataset.overviewAction) {
|
||||||
case 'goto':
|
case 'goto':
|
||||||
|
// Whole-row navigation is a convenience over the row's own button —
|
||||||
|
// don't fire it when the click was really the end of a text drag
|
||||||
|
// inside the row. (Clicks on the button itself always count.)
|
||||||
|
if (oa.classList.contains('ov-board-row')) {
|
||||||
|
const sel = window.getSelection && window.getSelection();
|
||||||
|
if (sel && !sel.isCollapsed && String(sel).trim()) break;
|
||||||
|
}
|
||||||
// Board rows can land on Updates pre-filtered (e.g. Security → the
|
// Board rows can land on Updates pre-filtered (e.g. Security → the
|
||||||
// affected apps only), so honor an optional data-filter on the way.
|
// affected apps only), so honor an optional data-filter on the way.
|
||||||
if (oa.dataset.filter) this.filter = oa.dataset.filter;
|
if (oa.dataset.filter) this.filter = oa.dataset.filter;
|
||||||
@ -425,6 +432,7 @@ class OverviewManager {
|
|||||||
text: `${waiting.length} update${waiting.length === 1 ? '' : 's'} waiting for you`,
|
text: `${waiting.length} update${waiting.length === 1 ? '' : 's'} waiting for you`,
|
||||||
sub: `${esc(listOf(waiting))} — a recovery snapshot is taken before each update`
|
sub: `${esc(listOf(waiting))} — a recovery snapshot is taken before each update`
|
||||||
+ (selfing ? ` · ${selfing} more will install automatically` : ''),
|
+ (selfing ? ` · ${selfing} more will install automatically` : ''),
|
||||||
|
nav: { tab: 'updates', filter: 'updates' },
|
||||||
actions: goto('updates', 'Review', 'updates')
|
actions: goto('updates', 'Review', 'updates')
|
||||||
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
|
+ `<button class="updater-btn updater-btn-primary" data-updater-action="update-all">Update all</button>`,
|
||||||
});
|
});
|
||||||
@ -438,6 +446,7 @@ class OverviewManager {
|
|||||||
hue: 'updates', icon: '⬆️', kind: 'ok',
|
hue: 'updates', icon: '⬆️', kind: 'ok',
|
||||||
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
|
text: `${pending.length} update${pending.length === 1 ? '' : 's'} installing automatically${winBit}`,
|
||||||
sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`,
|
sub: `${esc(listOf(pending))} — each is snapshotted first, and rolled back if it fails`,
|
||||||
|
nav: { tab: 'updates', filter: 'updates' },
|
||||||
actions: goto('updates', 'Review', 'updates'),
|
actions: goto('updates', 'Review', 'updates'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -469,6 +478,7 @@ class OverviewManager {
|
|||||||
text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`,
|
text: `${u.totalCves} known CVE${u.totalCves === 1 ? '' : 's'} across ${hit} app${hit === 1 ? '' : 's'}`,
|
||||||
badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '',
|
badge: worst ? ` <span class="updater-badge sev-${worst}">${worst}</span>` : '',
|
||||||
sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '),
|
sub: order.filter((s) => sev[s]).map((s) => `${sev[s]} ${s}`).join(' · '),
|
||||||
|
nav: { tab: 'updates', filter: 'security' },
|
||||||
actions: goto('updates', 'Review', 'security'),
|
actions: goto('updates', 'Review', 'security'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -482,6 +492,7 @@ class OverviewManager {
|
|||||||
hue: 'updater', icon: '✨', kind: 'warn',
|
hue: 'updater', icon: '✨', kind: 'warn',
|
||||||
text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`,
|
text: `${u.improvements} signed improvement${u.improvements === 1 ? '' : 's'} ready to apply`,
|
||||||
sub: 'individually reversible — a snapshot is taken first',
|
sub: 'individually reversible — a snapshot is taken first',
|
||||||
|
nav: { tab: 'improvements' },
|
||||||
actions: goto('improvements', 'View'),
|
actions: goto('improvements', 'View'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -497,6 +508,7 @@ class OverviewManager {
|
|||||||
hue: 'backups', icon: '💾', kind: 'warn',
|
hue: 'backups', icon: '💾', kind: 'warn',
|
||||||
text: 'No backup location configured',
|
text: 'No backup location configured',
|
||||||
sub: 'apps can’t be protected until there’s somewhere to back them up to',
|
sub: 'apps can’t be protected until there’s somewhere to back them up to',
|
||||||
|
nav: { tab: 'backups' },
|
||||||
actions: goto('backups', 'Open Backups'),
|
actions: goto('backups', 'Open Backups'),
|
||||||
});
|
});
|
||||||
} else if (b.never + b.old) {
|
} else if (b.never + b.old) {
|
||||||
@ -505,6 +517,7 @@ class OverviewManager {
|
|||||||
hue: 'backups', icon: '💾', kind: 'warn',
|
hue: 'backups', icon: '💾', kind: 'warn',
|
||||||
text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`,
|
text: `${n} app${n === 1 ? '' : 's'} need${n === 1 ? 's' : ''} a fresh backup`,
|
||||||
sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '),
|
sub: [b.never && `${b.never} never backed up`, b.old && `${b.old} older than a week`].filter(Boolean).join(' · '),
|
||||||
|
nav: { tab: 'backups' },
|
||||||
actions: goto('backups', 'Open Backups'),
|
actions: goto('backups', 'Open Backups'),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@ -516,8 +529,17 @@ class OverviewManager {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A row whose action is a plain "go to that tab" (Review / View / Open
|
||||||
|
// Backups) makes the WHOLE row a hit target for it — the button stays put as
|
||||||
|
// the visible affordance and the only focusable control, so the row is a
|
||||||
|
// mouse convenience layered on top rather than a nested role="button" (which
|
||||||
|
// would swallow the buttons it contains). Rows that only carry a real
|
||||||
|
// operation (Update all) or no action at all stay inert.
|
||||||
boardRow(r) {
|
boardRow(r) {
|
||||||
return `<div class="ov-board-row ${r.kind}" style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);">
|
const nav = r.nav
|
||||||
|
? ` data-overview-action="goto" data-tab="${r.nav.tab}"${r.nav.filter ? ` data-filter="${r.nav.filter}"` : ''}`
|
||||||
|
: '';
|
||||||
|
return `<div class="ov-board-row ${r.kind}${r.nav ? ' ov-clickable' : ''}"${nav} style="--page: var(--page-${r.hue}); --page-rgb: var(--page-${r.hue}-rgb);">
|
||||||
<span class="ov-dot ${r.kind}" aria-hidden="true"></span>
|
<span class="ov-dot ${r.kind}" aria-hidden="true"></span>
|
||||||
<span class="ov-board-icon" aria-hidden="true">${r.icon}</span>
|
<span class="ov-board-icon" aria-hidden="true">${r.icon}</span>
|
||||||
<div class="ov-board-main">
|
<div class="ov-board-main">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user