mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-20 21:22:37 -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;
|
max-height: 360px; padding: 4px 0; overflow-y: auto; z-index: 100;
|
||||||
}
|
}
|
||||||
.workspace-switcher.open .workspace-switcher-menu { display: block; }
|
.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 {
|
.workspace-switcher-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
padding: 8px 12px; cursor: pointer;
|
padding: 8px 12px; cursor: pointer;
|
||||||
|
|
@ -84,6 +99,8 @@ body {
|
||||||
}
|
}
|
||||||
.workspace-switcher-item:last-child { border-bottom: none; }
|
.workspace-switcher-item:last-child { border-bottom: none; }
|
||||||
.workspace-switcher-item:hover { background: var(--bg-input); }
|
.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.current { font-weight: 600; }
|
||||||
.workspace-switcher-item .check {
|
.workspace-switcher-item .check {
|
||||||
flex-shrink: 0; color: var(--accent); width: 14px;
|
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 sorted = [...list].sort((a, b) => a.name.localeCompare(b.name));
|
||||||
const current = sorted.find(w => w.id === currentId) || sorted[0];
|
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 = `
|
container.innerHTML = `
|
||||||
<button class="workspace-switcher-button" type="button" aria-haspopup="listbox" aria-expanded="false">
|
<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>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="workspace-switcher-menu" role="listbox">
|
<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 => {
|
${sorted.map(w => {
|
||||||
const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices');
|
const countStr = formatResourceCount(w.device_count, 'switcher.devices_count', 'switcher.no_devices');
|
||||||
const orgName = w.organization_name || '';
|
const orgName = w.organization_name || '';
|
||||||
|
|
@ -57,8 +68,10 @@ export function renderWorkspaceSwitcher(me) {
|
||||||
: orgName ? esc(orgName)
|
: orgName ? esc(orgName)
|
||||||
: countStr ? esc(countStr)
|
: countStr ? esc(countStr)
|
||||||
: '';
|
: '';
|
||||||
|
// Searchable haystack: org name + workspace name, lowercased.
|
||||||
|
const haystack = `${orgName} ${w.name}`.toLowerCase();
|
||||||
return `
|
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'}">
|
<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"/>
|
<polyline points="20 6 9 17 4 12"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|
@ -84,15 +97,86 @@ export function renderWorkspaceSwitcher(me) {
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('')}
|
}).join('')}
|
||||||
|
<div class="workspace-switcher-noresults" style="display:none">${t('switcher.no_matches')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
const button = container.querySelector('.workspace-switcher-button');
|
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) => {
|
button.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
const opening = !container.classList.contains('open');
|
const opening = !container.classList.contains('open');
|
||||||
container.classList.toggle('open');
|
container.classList.toggle('open');
|
||||||
button.setAttribute('aria-expanded', String(opening));
|
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
|
// 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 => {
|
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).
|
// Ignore clicks that originated on an icon button (each has its own handler).
|
||||||
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
|
if (e.target.closest('.workspace-switcher-pencil, .workspace-switcher-members')) return;
|
||||||
const wsId = item.dataset.workspaceId;
|
switchTo(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');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1144,6 +1144,9 @@ export default {
|
||||||
'switcher.devices_count_one': '1 device',
|
'switcher.devices_count_one': '1 device',
|
||||||
'switcher.devices_count_other': '{n} devices',
|
'switcher.devices_count_other': '{n} devices',
|
||||||
'switcher.no_devices': 'No 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).
|
// Workspace members (Slice 2A - read-only listing; 2B adds mutation keys).
|
||||||
'members.title': 'Workspace members',
|
'members.title': 'Workspace members',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue