diff --git a/frontend/css/main.css b/frontend/css/main.css index 60957d3..6247f1b 100644 --- a/frontend/css/main.css +++ b/frontend/css/main.css @@ -76,6 +76,21 @@ body { max-height: 360px; padding: 4px 0; overflow-y: auto; z-index: 100; } .workspace-switcher.open .workspace-switcher-menu { display: block; } +/* #16: sticky type-to-filter search header inside the (scrolling) menu. */ +.workspace-switcher-search { + position: sticky; top: 0; z-index: 1; + background: var(--bg-card); padding: 8px; + border-bottom: 1px solid var(--border); +} +.workspace-switcher-search input { + width: 100%; box-sizing: border-box; padding: 6px 8px; + background: var(--bg-input); border: 1px solid var(--border); + border-radius: var(--radius); color: var(--text-primary); font-size: 13px; +} +.workspace-switcher-search input:focus { outline: none; border-color: var(--accent); } +.workspace-switcher-noresults { + padding: 12px; color: var(--text-muted); font-size: 13px; text-align: center; +} .workspace-switcher-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; @@ -84,6 +99,8 @@ body { } .workspace-switcher-item:last-child { border-bottom: none; } .workspace-switcher-item:hover { background: var(--bg-input); } +/* keyboard-cursor highlight (arrow keys) - same surface as hover */ +.workspace-switcher-item.highlighted { background: var(--bg-input); } .workspace-switcher-item.current { font-weight: 600; } .workspace-switcher-item .check { flex-shrink: 0; color: var(--accent); width: 14px; diff --git a/frontend/js/components/workspace-switcher.js b/frontend/js/components/workspace-switcher.js index 1f324b9..69dd68a 100644 --- a/frontend/js/components/workspace-switcher.js +++ b/frontend/js/components/workspace-switcher.js @@ -42,6 +42,12 @@ export function renderWorkspaceSwitcher(me) { const sorted = [...list].sort((a, b) => a.name.localeCompare(b.name)); const current = sorted.find(w => w.id === currentId) || sorted[0]; + // Issue #16: show a type-to-filter search box once the list is big enough to + // be painful to scroll (MSPs run 100+ orgs). Below the threshold a plain list + // is fine. The full list is already loaded from /me, so filtering is client-side. + const SHOW_SEARCH_THRESHOLD = 8; + const showSearch = sorted.length >= SHOW_SEARCH_THRESHOLD; + container.innerHTML = `
+ ${showSearch ? ` + ` : ''} ${sorted.map(w => { const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices'); const orgName = w.organization_name || ''; @@ -57,8 +68,10 @@ export function renderWorkspaceSwitcher(me) { : orgName ? esc(orgName) : countStr ? esc(countStr) : ''; + // Searchable haystack: org name + workspace name, lowercased. + const haystack = `${orgName} ${w.name}`.toLowerCase(); return ` -
+
@@ -84,15 +97,86 @@ export function renderWorkspaceSwitcher(me) {
`; }).join('')} +
`; const button = container.querySelector('.workspace-switcher-button'); + const searchInput = container.querySelector('.ws-search-input'); // null below threshold + + // Shared switch action (used by click and keyboard Enter). + async function switchTo(wsId) { + if (wsId === currentId) { container.classList.remove('open'); return; } + try { + const resp = await api.switchWorkspace(wsId); + if (resp?.token) { + localStorage.setItem('token', resp.token); + window.location.reload(); + } else { + showToast('Switch returned no token', 'error'); + } + } catch (err) { + showToast(err.message || 'Failed to switch workspace', 'error'); + } + } + + // ---- type-to-filter + keyboard navigation (only when the search box renders) ---- + const allItems = Array.from(container.querySelectorAll('.workspace-switcher-item')); + const noResults = container.querySelector('.workspace-switcher-noresults'); + let highlightIdx = -1; + const visibleItems = () => allItems.filter(it => it.style.display !== 'none'); + + function setHighlight(idx) { + const vis = visibleItems(); + allItems.forEach(it => it.classList.remove('highlighted')); + if (!vis.length) { highlightIdx = -1; return; } + highlightIdx = Math.max(0, Math.min(idx, vis.length - 1)); + const el = vis[highlightIdx]; + el.classList.add('highlighted'); + el.scrollIntoView({ block: 'nearest' }); + } + + function applyFilter(q) { + const query = (q || '').trim().toLowerCase(); + let anyVisible = false; + for (const it of allItems) { + const match = !query || it.dataset.search.includes(query); + it.style.display = match ? '' : 'none'; + if (match) anyVisible = true; + } + if (noResults) noResults.style.display = anyVisible ? 'none' : ''; + setHighlight(0); + } + + if (searchInput) { + searchInput.addEventListener('input', () => applyFilter(searchInput.value)); + searchInput.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); setHighlight(highlightIdx + 1); } + else if (e.key === 'ArrowUp') { e.preventDefault(); setHighlight(highlightIdx - 1); } + else if (e.key === 'Enter') { + e.preventDefault(); + const el = visibleItems()[highlightIdx]; + if (el) switchTo(el.dataset.workspaceId); + } else if (e.key === 'Escape') { + e.preventDefault(); + container.classList.remove('open'); + button.setAttribute('aria-expanded', 'false'); + button.focus(); + } + }); + } + button.addEventListener('click', (e) => { e.stopPropagation(); const opening = !container.classList.contains('open'); container.classList.toggle('open'); button.setAttribute('aria-expanded', String(opening)); + // On open, reset the filter and focus the search box for immediate typing. + if (opening && searchInput) { + searchInput.value = ''; + applyFilter(''); + setTimeout(() => searchInput.focus(), 0); + } }); // Pencil click opens the rename modal. Must stopPropagation so the click @@ -121,22 +205,10 @@ export function renderWorkspaceSwitcher(me) { }); container.querySelectorAll('.workspace-switcher-item').forEach(item => { - item.addEventListener('click', async (e) => { + item.addEventListener('click', (e) => { // Ignore clicks that originated on an icon button (each has its own handler). if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return; - const wsId = item.dataset.workspaceId; - if (wsId === currentId) { container.classList.remove('open'); return; } - try { - const resp = await api.switchWorkspace(wsId); - if (resp?.token) { - localStorage.setItem('token', resp.token); - window.location.reload(); - } else { - showToast('Switch returned no token', 'error'); - } - } catch (err) { - showToast(err.message || 'Failed to switch workspace', 'error'); - } + switchTo(item.dataset.workspaceId); }); }); diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js index 3f36d0e..d15cf95 100644 --- a/frontend/js/i18n/en.js +++ b/frontend/js/i18n/en.js @@ -1144,6 +1144,9 @@ export default { 'switcher.devices_count_one': '1 device', 'switcher.devices_count_other': '{n} devices', 'switcher.no_devices': 'No devices', + // #16: searchable org/workspace switcher + 'switcher.search_placeholder': 'Search organizations…', + 'switcher.no_matches': 'No matches', // Workspace members (Slice 2A - read-only listing; 2B adds mutation keys). 'members.title': 'Workspace members',