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:
@@ -596,10 +596,11 @@ function buildSidebarHTML(session, currentSession) {
|
|||||||
// which is not a registered remote_instance, so routing through federation would 404.
|
// which is not a registered remote_instance, so routing through federation would 404.
|
||||||
var _sidebarEffRemoteId = session.remoteId != null ? String(session.remoteId) : '';
|
var _sidebarEffRemoteId = session.remoteId != null ? String(session.remoteId) : '';
|
||||||
return (
|
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">` +
|
`<div class="sidebar-item-header">` +
|
||||||
`<span class="sidebar-item-name">${escapedName}</span>` +
|
`<span class="sidebar-item-name">${escapedName}</span>` +
|
||||||
badgeHtml +
|
badgeHtml +
|
||||||
|
`<button class="tile-options-btn" data-session="${escapedName}" aria-label="Session options" aria-haspopup="true">⋮</button>` +
|
||||||
`</div>` +
|
`</div>` +
|
||||||
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
`<div class="sidebar-item-body"><pre>${ansiToHtml(lastLines)}</pre></div>` +
|
||||||
`</article>`
|
`</article>`
|
||||||
@@ -738,6 +739,7 @@ function renderSidebar(sessions, currentSession) {
|
|||||||
const name = item.dataset.session;
|
const name = item.dataset.session;
|
||||||
const remoteId = item.dataset.remoteId || '';
|
const remoteId = item.dataset.remoteId || '';
|
||||||
on(item, 'click', (e) => {
|
on(item, 'click', (e) => {
|
||||||
|
if (e.target.closest && e.target.closest('.tile-options-btn')) return;
|
||||||
if (name !== currentSession) openSession(name, { remoteId });
|
if (name !== currentSession) openSession(name, { remoteId });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -658,6 +658,24 @@ body {
|
|||||||
flex-shrink: 0;
|
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 {
|
.sidebar-empty {
|
||||||
padding: 16px 12px;
|
padding: 16px 12px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
|
|||||||
@@ -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');
|
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 ---
|
// --- renderSidebar ---
|
||||||
|
|
||||||
test('renderSidebar populates sidebar-list innerHTML when viewMode is fullscreen', () => {
|
test('renderSidebar populates sidebar-list innerHTML when viewMode is fullscreen', () => {
|
||||||
|
|||||||
@@ -2362,3 +2362,22 @@ def test_device_badge_height_matches_flyout_button() -> None:
|
|||||||
".device-badge must have increased height to match the ~24px ⋮ button — "
|
".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"
|
"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)"
|
||||||
|
)
|
||||||
|
|||||||
@@ -4632,3 +4632,82 @@ def test_domcontentloaded_calls_render_view_dropdown_after_restore() -> None:
|
|||||||
"DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
|
"DOMContentLoaded must call renderViewDropdown() after restoreState() so the "
|
||||||
"dropdown label correctly reflects _activeView on page reload"
|
"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;"
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user