feat: add flyout menu to sidebar session items

- Add data-session-key attribute to buildSidebarHTML article element so
  openFlyoutMenu() can read session info via closest('[data-session-key]')
- Add tile-options-btn button (with aria-label and aria-haspopup) inside
  sidebar-item-header — reuses the same class as tile version so the
  existing delegated document click handler picks it up automatically
- Guard renderSidebar click handler against .tile-options-btn clicks so
  clicking ⋮ doesn't also trigger openSession()
- Add .sidebar-item-header .tile-options-btn CSS: compact 20×20px, subtle
  opacity-based visibility to fit the narrower sidebar layout
- Tests: buildSidebarHTML data-session-key, tile-options-btn presence/aria,
  renderSidebar click guard, CSS sidebar btn styling
This commit is contained in:
Brian Krabach
2026-04-16 08:58:05 -07:00
parent 600f34ebe0
commit 769621a009
5 changed files with 152 additions and 1 deletions
+3 -1
View File
@@ -596,10 +596,11 @@ function buildSidebarHTML(session, currentSession) {
// which is not a registered remote_instance, so routing through federation would 404.
var _sidebarEffRemoteId = session.remoteId != null ? String(session.remoteId) : '';
return (
`<article class="${classes}" data-session="${escapedName}" data-remote-id="${escapeHtml(_sidebarEffRemoteId)}" tabindex="0" role="listitem">` +
`<article class="${classes}" data-session="${escapedName}" data-session-key="${escapeHtml(session.sessionKey || name)}" data-remote-id="${escapeHtml(_sidebarEffRemoteId)}" tabindex="0" role="listitem">` +
`<div class="sidebar-item-header">` +
`<span class="sidebar-item-name">${escapedName}</span>` +
badgeHtml +
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">&#8942;</button>` +
`</div>` +
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
`</article>`
@@ -738,6 +739,7 @@ function renderSidebar(sessions, currentSession) {
const name = item.dataset.session;
const remoteId = item.dataset.remoteId || '';
on(item, 'click', (e) => {
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
if (name !== currentSession) openSession(name, { remoteId });
});
});
+18
View File
@@ -658,6 +658,24 @@ body {
flex-shrink: 0;
}
.sidebar-item-header .tile-options-btn {
width: 20px;
height: 20px;
font-size: 12px;
flex-shrink: 0;
background: transparent;
border-color: transparent;
opacity: 0.6;
}
.sidebar-item:hover .sidebar-item-header .tile-options-btn,
.sidebar-item-header .tile-options-btn:hover,
.sidebar-item-header .tile-options-btn:focus-visible {
opacity: 1;
background: var(--bg-surface);
border-color: var(--border-subtle);
}
.sidebar-empty {
padding: 16px 12px;
color: var(--text-muted);
+33
View File
@@ -1717,6 +1717,39 @@ test('buildSidebarHTML includes snapshot preview in a pre element', () => {
assert.ok(/<pre>[\s\S]*line1[\s\S]*<\/pre>/.test(html), 'snapshot content should be inside a <pre> element');
});
// --- buildSidebarHTML flyout menu button ---
test('buildSidebarHTML article element has data-session-key attribute', () => {
const session = { name: 'my-session', sessionKey: 'key::my-session', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(html.includes('data-session-key='), 'article must have data-session-key attribute');
assert.ok(html.includes('data-session-key="key::my-session"'), 'data-session-key must use sessionKey value');
});
test('buildSidebarHTML article element uses session name as data-session-key when sessionKey is absent', () => {
const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(html.includes('data-session-key="my-session"'), 'data-session-key must fall back to session.name when sessionKey absent');
});
test('buildSidebarHTML header contains a tile-options-btn button', () => {
const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(html.includes('tile-options-btn'), 'sidebar item header must include tile-options-btn button');
});
test('buildSidebarHTML tile-options-btn has aria-label="Session options"', () => {
const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(html.includes('aria-label="Session options"'), 'tile-options-btn must have aria-label="Session options"');
});
test('buildSidebarHTML tile-options-btn has aria-haspopup="true"', () => {
const session = { name: 'my-session', snapshot: '', bell: { unseen_count: 0 } };
const html = app.buildSidebarHTML(session, '');
assert.ok(html.includes('aria-haspopup="true"'), 'tile-options-btn must have aria-haspopup="true"');
});
// --- renderSidebar ---
test('renderSidebar populates sidebar-list innerHTML when viewMode is fullscreen', () => {
+19
View File
@@ -2362,3 +2362,22 @@ def test_device_badge_height_matches_flyout_button() -> None:
".device-badge must have increased height to match the ~24px ⋮ button — "
"add min-height or increase padding/line-height so the badge is ~22-24px tall"
)
# ── Sidebar flyout menu (feat: add flyout menu to sidebar session items) ──────
def test_css_sidebar_options_btn_styled() -> None:
"""CSS must contain a rule targeting .tile-options-btn inside .sidebar-item-header or .sidebar-item."""
css = read_css()
# Look for a CSS rule that targets the options btn specifically in the sidebar context
has_sidebar_btn_style = (
".sidebar-item-header .tile-options-btn" in css
or ".sidebar-item .tile-options-btn" in css
or ".sidebar-item-header > .tile-options-btn" in css
)
assert has_sidebar_btn_style, (
"CSS must include a rule targeting .tile-options-btn inside .sidebar-item-header "
"or .sidebar-item to ensure the button fits the sidebar layout "
"(smaller and compact for the narrower sidebar width)"
)
+79
View File
@@ -4632,3 +4632,82 @@ def test_domcontentloaded_calls_render_view_dropdown_after_restore() -> None:
"DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
"dropdown label correctly reflects _activeView on page reload"
)
# ── Sidebar flyout menu (feat: add flyout menu to sidebar session items) ──────
def test_build_sidebar_html_article_has_data_session_key() -> None:
"""buildSidebarHTML must include data-session-key on the article element."""
match = re.search(
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert "data-session-key" in body, (
"buildSidebarHTML must include data-session-key attribute on the article element "
"so openFlyoutMenu can look up session data via closest('[data-session-key]')"
)
def test_build_sidebar_html_has_tile_options_btn() -> None:
"""buildSidebarHTML must include a .tile-options-btn button in the header."""
match = re.search(
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert "tile-options-btn" in body, (
"buildSidebarHTML must include a button with class 'tile-options-btn' "
"in the sidebar item header so users can access the flyout menu"
)
def test_build_sidebar_html_options_btn_has_aria_label() -> None:
"""buildSidebarHTML tile-options-btn must have aria-label='Session options'."""
match = re.search(
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert 'aria-label="Session options"' in body or "aria-label='Session options'" in body, (
"buildSidebarHTML tile-options-btn must have aria-label='Session options' "
"for accessibility (same as tile version)"
)
def test_build_sidebar_html_options_btn_has_aria_haspopup() -> None:
"""buildSidebarHTML tile-options-btn must have aria-haspopup='true'."""
match = re.search(
r"function buildSidebarHTML\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "buildSidebarHTML function not found"
body = match.group(1)
assert 'aria-haspopup="true"' in body or "aria-haspopup='true'" in body, (
"buildSidebarHTML tile-options-btn must have aria-haspopup='true' "
"for accessibility (same as tile version)"
)
def test_render_sidebar_click_handler_guards_tile_options_btn() -> None:
"""renderSidebar click handler must guard against tile-options-btn clicks."""
match = re.search(
r"function renderSidebar\s*\(.*?\)\s*\{(.*?)(?=\nfunction |\n// )",
_JS,
re.DOTALL,
)
assert match, "renderSidebar function not found"
body = match.group(1)
assert "tile-options-btn" in body, (
"renderSidebar click handler must guard against .tile-options-btn clicks "
"so clicking ⋮ doesn't also trigger openSession() — "
"use: if (e.target.closest('.tile-options-btn')) return;"
)