Merge pull request #24 from screentinker/feat/searchable-org-switcher-16

feat(switcher): searchable / filterable org switcher (#16)
This commit is contained in:
screentinker 2026-06-08 16:32:45 -05:00 committed by GitHub
commit c1f2f0a637
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 107 additions and 15 deletions

View file

@ -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;

View file

@ -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 = `
<button class="workspace-switcher-button" type="button" aria-haspopup="listbox" aria-expanded="false">
<span class="ws-name" style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(current.name)}</span>
@ -50,6 +56,11 @@ export function renderWorkspaceSwitcher(me) {
</svg>
</button>
<div class="workspace-switcher-menu" role="listbox">
${showSearch ? `
<div class="workspace-switcher-search">
<input type="text" class="ws-search-input" placeholder="${t('switcher.search_placeholder')}"
autocomplete="off" autocapitalize="off" spellcheck="false" aria-label="${t('switcher.search_placeholder')}">
</div>` : ''}
${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 `
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" role="option">
<div class="workspace-switcher-item ${w.id === currentId ? 'current' : ''}" data-workspace-id="${esc(w.id)}" data-search="${esc(haystack)}" role="option">
<svg class="check" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" style="${w.id === currentId ? '' : 'visibility:hidden'}">
<polyline points="20 6 9 17 4 12"/>
</svg>
@ -84,15 +97,86 @@ export function renderWorkspaceSwitcher(me) {
</div>
`;
}).join('')}
<div class="workspace-switcher-noresults" style="display:none">${t('switcher.no_matches')}</div>
</div>
`;
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);
});
});

View file

@ -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',