mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-14 18:22:46 -06:00
feat(switcher): searchable/filterable org switcher (#16)
At MSP scale (100+ orgs) the org/workspace switcher dropdown was an un-scrollable wall. Add a type-to-filter search box. - Sticky search input at the top of the switcher menu, shown once the list reaches a threshold (>= 8 workspaces); below that the plain list is fine. - Live client-side filter: case-insensitive substring match on "organization name + workspace name" (data-search haystack per row). The full list is already loaded from /me, so no extra requests. - Keyboard nav: search is auto-focused on open; type filters, ArrowUp/Down move a highlight among visible rows, Enter selects (switches), Esc closes. - "No matches" state when nothing matches; opening resets the filter. - Refactored the switch action into a shared switchTo() used by both click and Enter. Frontend only. Verified headless: filter narrows live, no-match state, clear restores, arrow-key highlight. EN i18n added. Closes #16.
This commit is contained in:
parent
0f84cac440
commit
1f62ffbc3b
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue