feat: search finds what is inside a tab, and the menu head reads toggle, mark, search
Search now reaches sub-navigation, because people look for "bili" rather than "Calculators". It reads whatever a loaded component exposes — data-calc, data-subtab, data-section — so it covers every tab with sub-navigation instead of one hard-coded list, and the relevant components are warmed when the palette opens. Opening a result survives the component still loading, and reaching one from the assistant navigates first and opens it after. Menu head order is now show/hide menu, then the mark, then search. In the collapsed rail every item is the same centred 52px box, so the icons finally share one axis — the brand was a flex row with a gap and sat off-centre from the buttons beneath it. Settings, FAQ and Admin are no longer listed in the menu: they already have a place in the account card, and listing them twice only made the tab list longer. Their sections remain, and activateTab already tolerates a tab whose section exists without a sidebar button. The retry loops are named functions rather than IIFEs, which the module entrypoint rules forbid. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GmpYHPSLGmXGZMyLpn2Lbe
This commit is contained in:
parent
2d6eaccd96
commit
531996de1e
5 changed files with 121 additions and 27 deletions
|
|
@ -7,12 +7,12 @@
|
|||
<!-- Brand, collapse and search share one row, as in the reference UIs.
|
||||
Three stacked full-width rows wasted the top of the menu. -->
|
||||
<div class="menu-head">
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
<div class="menu-brand">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>PedAI</h1>
|
||||
</div>
|
||||
<button type="button" class="menu-icon-btn" data-menu-search title="Search" aria-label="Search"><i class="fas fa-magnifying-glass"></i></button>
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
</div>
|
||||
<div class="assistant-mode-switch" role="tablist" aria-label="Assistant or workspace">
|
||||
<button type="button" role="tab" class="active" aria-selected="true" data-assistant-mode="assistant"><i class="fas fa-brain"></i> Assistant</button>
|
||||
|
|
|
|||
|
|
@ -49,7 +49,8 @@ body{font-family:'Inter',system-ui,sans-serif;background:var(--g50);color:var(--
|
|||
the account card at its foot, and the content gets the full height. */
|
||||
/* Icon, search, collapse — nothing else earns a place in the head row. */
|
||||
.menu-head { display:flex; align-items:center; gap:4px; padding:10px 8px 8px 12px; }
|
||||
.menu-head .menu-icon-btn:first-of-type { margin-left:auto; }
|
||||
/* Toggle leads, the mark takes the slack, search closes the row. */
|
||||
.menu-brand { margin-right:auto; }
|
||||
.menu-brand { display:flex; align-items:center; gap:8px; flex:0 0 auto; min-width:0; }
|
||||
.menu-brand h1 { font-size:15px; font-weight:700; color:var(--g900); margin:0; line-height:1; letter-spacing:-.01em; }
|
||||
.menu-brand i { font-size:16px; color:var(--blue); flex:0 0 auto; }
|
||||
|
|
@ -1277,9 +1278,12 @@ body.menu-hidden .sidebar-section-label,
|
|||
body.menu-hidden .menu-brand h1,
|
||||
body.menu-hidden .account-id,
|
||||
body.menu-hidden .account-chevron { display:none; }
|
||||
body.menu-hidden .menu-head { flex-direction:column; gap:6px; padding:10px 0 8px; align-items:center; }
|
||||
body.menu-hidden .menu-brand { flex:0 0 auto; justify-content:center; }
|
||||
body.menu-hidden .menu-brand i { font-size:19px; }
|
||||
body.menu-hidden .menu-head { flex-direction:column; gap:4px; padding:10px 0 8px; align-items:stretch; }
|
||||
/* Every item is the same 52px-wide centred box, so the icons share one axis. */
|
||||
body.menu-hidden .menu-head > * { width:100%; margin:0; justify-content:center; }
|
||||
body.menu-hidden .menu-brand { display:flex; align-items:center; justify-content:center; height:32px; gap:0; }
|
||||
body.menu-hidden .menu-brand i { font-size:19px; width:auto; }
|
||||
body.menu-hidden .menu-icon-btn { width:32px; height:32px; margin:0 auto; }
|
||||
body.menu-hidden .account-card { padding:8px 0; }
|
||||
body.menu-hidden .account-card-btn { justify-content:center; padding:6px 0; }
|
||||
body.menu-hidden .account-menu { left:8px; right:auto; width:210px; }
|
||||
|
|
|
|||
|
|
@ -178,12 +178,12 @@
|
|||
<!-- Brand, collapse and search share one row, as in the reference UIs.
|
||||
Three stacked full-width rows wasted the top of the menu. -->
|
||||
<div class="menu-head">
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
<div class="menu-brand">
|
||||
<i class="fas fa-stethoscope"></i>
|
||||
<h1>PedAI</h1>
|
||||
</div>
|
||||
<button type="button" class="menu-icon-btn" data-menu-search title="Search" aria-label="Search"><i class="fas fa-magnifying-glass"></i></button>
|
||||
<button type="button" class="menu-icon-btn assistant-menu-toggle" data-menu-toggle aria-expanded="true" title="Hide menu" aria-label="Hide menu"><i class="fas fa-angles-left"></i></button>
|
||||
</div>
|
||||
<!-- The app IS workspace mode. One switch, shown in both places and
|
||||
styled identically, so moving between them is a highlight change
|
||||
|
|
@ -272,19 +272,6 @@
|
|||
<i class="fas fa-book"></i>
|
||||
<span>Docs</span>
|
||||
</button>
|
||||
<span class="sidebar-section-label">Account</span>
|
||||
<button class="tab-btn" data-tab="settings">
|
||||
<i class="fas fa-cog"></i>
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
<button class="tab-btn" data-tab="faq">
|
||||
<i class="fas fa-circle-question"></i>
|
||||
<span>FAQ</span>
|
||||
</button>
|
||||
<button class="tab-btn hidden" data-tab="admin" id="admin-tab-btn">
|
||||
<i class="fas fa-user-shield"></i>
|
||||
<span>Admin</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Account card. The same markup renders in the assistant rail, so the
|
||||
|
|
|
|||
|
|
@ -272,21 +272,59 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
var searchInput = document.getElementById('menu-search-input');
|
||||
var searchResults = document.getElementById('menu-search-results');
|
||||
|
||||
// Sub-navigation is only present once its component has loaded, so give it a
|
||||
// moment rather than clicking into an empty tab.
|
||||
function openSubItem(sub, tabName, attempts) {
|
||||
var host = tabName ? document.getElementById(tabName + '-tab') : document;
|
||||
var selector = '[data-calc="' + sub + '"], [data-subtab="' + sub + '"], [data-section="' + sub + '"]';
|
||||
var target = host && host.querySelector(selector);
|
||||
if (target) { target.click(); return; }
|
||||
if (attempts > 0) setTimeout(function() { openSubItem(sub, tabName, attempts - 1); }, 110);
|
||||
}
|
||||
|
||||
// Sub-navigation inside a tab — the calculator pills, the well-visit ages, the
|
||||
// exam regions — is what people actually look for ("bili", not "Calculators").
|
||||
// Read generically from whatever a loaded component exposes, so this covers
|
||||
// every tab that has sub-navigation rather than one hard-coded list.
|
||||
var SUB_NAV_SELECTOR = '[data-calc], [data-subtab], [data-section], .calc-nav-pill';
|
||||
|
||||
function subItemsFor(tabEl, tabName, tabTitle) {
|
||||
if (!tabEl || !tabEl.dataset.loaded) return []; // not loaded yet; nothing to read
|
||||
var seen = {};
|
||||
return Array.prototype.map.call(tabEl.querySelectorAll(SUB_NAV_SELECTOR), function(node) {
|
||||
var key = node.getAttribute('data-calc') || node.getAttribute('data-subtab') || node.getAttribute('data-section');
|
||||
var title = (node.textContent || '').trim();
|
||||
if (!key || !title || title.length > 48 || seen[key + title]) return null;
|
||||
seen[key + title] = true;
|
||||
return { id: tabName, sub: key, title: title, meta: tabTitle, icon: 'fas fa-arrow-turn-up fa-rotate-90' };
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function searchSources() {
|
||||
// The assistant publishes its chats; anything else searches the menu.
|
||||
if (window.location.pathname === '/assistant' && typeof window.assistantSearchableChats === 'function') {
|
||||
return { kind: 'chats', items: window.assistantSearchableChats() };
|
||||
}
|
||||
return { kind: 'tabs', items: Array.prototype.map.call(document.querySelectorAll('.tab-btn'), function(tab) {
|
||||
var items = [];
|
||||
Array.prototype.forEach.call(document.querySelectorAll('.tab-btn'), function(tab) {
|
||||
var name = tab.getAttribute('data-tab');
|
||||
if (!name || tab.classList.contains('hidden')) return;
|
||||
var label = tab.querySelector('span');
|
||||
var icon = tab.querySelector('i');
|
||||
return {
|
||||
id: tab.getAttribute('data-tab'),
|
||||
title: label ? label.textContent : tab.getAttribute('data-tab'),
|
||||
icon: icon ? icon.className : 'fas fa-circle',
|
||||
hidden: tab.classList.contains('hidden')
|
||||
};
|
||||
}).filter(function(t) { return t.id && !t.hidden; }) };
|
||||
var title = label ? label.textContent : name;
|
||||
items.push({ id: name, title: title, icon: icon ? icon.className : 'fas fa-circle' });
|
||||
items = items.concat(subItemsFor(document.getElementById(name + '-tab'), name, title));
|
||||
});
|
||||
return { kind: 'tabs', items: items };
|
||||
}
|
||||
|
||||
// A component only exposes its sub-navigation once loaded, so warm the tabs
|
||||
// that have any. Cheap: each is fetched once and cached by the loader.
|
||||
function warmSearchableTabs() {
|
||||
['calculators', 'wellvisit', 'peguide', 'vaxschedule'].forEach(function(name) {
|
||||
var el = document.getElementById(name + '-tab');
|
||||
if (el && el.hasAttribute('data-component') && !el.dataset.loaded) loadComponent(el);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSearch(term) {
|
||||
|
|
@ -313,6 +351,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
row.setAttribute('role', 'option');
|
||||
row.setAttribute('data-search-kind', source.kind);
|
||||
row.setAttribute('data-search-id', item.id);
|
||||
if (item.sub) row.setAttribute('data-search-sub', item.sub);
|
||||
row.innerHTML = '<i class="' + (item.icon || 'fas fa-message') + '"></i><span></span>' +
|
||||
(item.meta ? '<em></em>' : '');
|
||||
row.querySelector('span').textContent = item.title || '';
|
||||
|
|
@ -324,6 +363,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
function openSearch() {
|
||||
if (!searchModal) return;
|
||||
searchModal.hidden = false;
|
||||
warmSearchableTabs();
|
||||
if (searchInput) {
|
||||
searchInput.value = '';
|
||||
searchInput.placeholder = searchSources().kind === 'chats' ? 'Search chats...' : 'Search the workspace...';
|
||||
|
|
@ -342,6 +382,20 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
if (typeof window.assistantOpenChat === 'function') window.assistantOpenChat(id);
|
||||
return;
|
||||
}
|
||||
// A sub-item opens its tab and then activates the entry inside it.
|
||||
var sub = row.getAttribute('data-search-sub');
|
||||
if (sub) {
|
||||
if (window.location.pathname === '/assistant') {
|
||||
try { localStorage.setItem('ped_last_tab', id); localStorage.setItem('ped_pending_sub', sub); } catch (e) {}
|
||||
window.location.href = '/';
|
||||
return;
|
||||
}
|
||||
activateTab(id);
|
||||
// The component may still be loading, so retry briefly rather than
|
||||
// clicking into an empty tab.
|
||||
openSubItem(sub, id, 20);
|
||||
return;
|
||||
}
|
||||
// A workspace result lives in the app, so reaching one from the assistant
|
||||
// leaves it — the same rule the account menu follows.
|
||||
if (window.location.pathname === '/assistant') {
|
||||
|
|
@ -380,6 +434,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||
}
|
||||
});
|
||||
|
||||
// A sub-item chosen from the assistant navigates here first; open it once the
|
||||
// component it lives in is ready.
|
||||
try {
|
||||
var pendingSub = localStorage.getItem('ped_pending_sub');
|
||||
if (pendingSub) {
|
||||
localStorage.removeItem('ped_pending_sub');
|
||||
openSubItem(pendingSub, null, 30);
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
// Expose activateTab globally so auth.js can call it after login
|
||||
window.activateTab = activateTab;
|
||||
|
||||
|
|
|
|||
|
|
@ -491,3 +491,42 @@ test('the menu can always be reopened after it is hidden', () => {
|
|||
kept + ' stays visible in the collapsed rail');
|
||||
}
|
||||
});
|
||||
|
||||
test('search reaches the things inside a tab, not just the tab', () => {
|
||||
const app = read('public/js/app.js');
|
||||
// People look for "bili", not "Calculators". Sub-navigation is read
|
||||
// generically from whatever a loaded component exposes, so this covers every
|
||||
// tab with sub-navigation rather than one hard-coded list.
|
||||
assert.match(app, /SUB_NAV_SELECTOR = '\[data-calc\], \[data-subtab\], \[data-section\], \.calc-nav-pill'/);
|
||||
assert.match(app, /function subItemsFor\(/);
|
||||
assert.match(app, /if \(!tabEl \|\| !tabEl\.dataset\.loaded\) return \[\];/,
|
||||
'an unloaded component exposes nothing, so it is skipped rather than guessed at');
|
||||
assert.match(app, /function warmSearchableTabs\(/, 'and those components are warmed when the palette opens');
|
||||
|
||||
// Opening one has to survive the component still loading.
|
||||
assert.match(app, /function openSubItem\(sub, tabName, attempts\)/);
|
||||
assert.match(app, /if \(attempts > 0\) setTimeout/, 'it retries rather than clicking into an empty tab');
|
||||
// Reached from the assistant, it has to navigate first and open after.
|
||||
assert.match(app, /localStorage\.setItem\('ped_pending_sub', sub\)/);
|
||||
|
||||
const calculators = read('public/components/calculators.html');
|
||||
assert.match(calculators, /data-calc="bili"/, 'the labels search reads are real');
|
||||
});
|
||||
|
||||
test('the menu head reads toggle, mark, search — and account tabs are not listed twice', () => {
|
||||
for (const file of ['public/index.html', 'public/components/assistant.html']) {
|
||||
const markup = read(file);
|
||||
const head = markup.slice(markup.indexOf('<div class="menu-head">'), markup.indexOf('assistant-mode-switch'));
|
||||
const order = [...head.matchAll(/assistant-menu-toggle|menu-brand|data-menu-search/g)].map(m => m[0]);
|
||||
assert.deepEqual(order, ['assistant-menu-toggle', 'menu-brand', 'data-menu-search'],
|
||||
file + ': show/hide menu first, then the mark, then search');
|
||||
}
|
||||
// Settings, FAQ and Admin live in the account card; listing them in the menu
|
||||
// too only made the tab list longer.
|
||||
const index = read('public/index.html');
|
||||
for (const tab of ['settings', 'faq', 'admin']) {
|
||||
assert.doesNotMatch(index, new RegExp('class="tab-btn[^"]*" data-tab="' + tab + '"'), tab + ' is not in the menu list');
|
||||
assert.match(index, new RegExp('id="' + tab + '-tab"'), tab + ' still has its section');
|
||||
assert.match(index, new RegExp('data-account-tab="' + tab + '"|role="menuitem"'), tab + ' is reachable from the account card');
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue