Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1691,7 +1691,29 @@
// 7. Subtitle
// =========================================================================

function renderSubtitle(text?: string): void {
function renderHeaderSummary(subtitle: HTMLElement, tickers: Iterable<string>, activeTicker: string | null, activate: (ticker: string) => void): void {
const panel = document.getElementById('app-summary');
if (!panel) return;
// Move existing nodes: provenance links and their handlers remain intact.
panel.replaceChildren(...Array.from(subtitle.childNodes));
subtitle.replaceChildren();
const selected = [...tickers].sort();
if (!selected.length) return;
subtitle.append(document.createTextNode(`${selected.length} selected: `));
selected.forEach((ticker, index) => {
if (index) subtitle.append(document.createTextNode(', '));
const link = document.createElement('a');
link.href = '#';
link.dataset.headerFund = ticker;
link.title = `View ${ticker} details`;
link.className = `font-semibold ${ticker === activeTicker ? 'text-blue-700 dark:text-blue-300 underline' : 'text-blue-600 dark:text-blue-400 hover:underline'}`;
link.textContent = ticker;
link.addEventListener('click', event => { event.preventDefault(); activate(ticker); });
subtitle.append(link);
});
}

function renderSubtitleDetails(text?: string): void {
const generated = state.generatedAt ? new Date(state.generatedAt).toLocaleString() : '';
const countsText = state.counts
? `${state.counts.funds} ETFs · ${(state.counts.holdings || 0).toLocaleString('en-US')} holdings rows · ${(state.counts.history || 0).toLocaleString('en-US')} history rows`
Expand All @@ -1703,6 +1725,23 @@
`;
}

function renderSubtitle(text?: string): void {
renderSubtitleDetails(text);
renderHeaderSummary(el.subtitle, state.selected, state.activeFundTicker, activateHeaderFund);
}

function activateHeaderFund(ticker: string): void {
if (!state.selected.has(ticker)) return;
const previous = state.activeFundTicker;
state.activeFundTicker = ticker;
if (!isDetailTab(state.activeTab)) state.activeTab = 'detail:overview';
if (previous !== ticker) resetSheetPaging();
applySortForTab(state.activeTab);
persistSelection();
syncSearchInput();
render();
}

function setStatusRow(message: string, tone: 'info' | 'error'): void {
el.tableBody.innerHTML = `<tr><td colspan="10" class="py-12 text-center ${tone === 'error' ? 'text-rose-500 dark:text-rose-300' : 'text-slate-400 dark:text-slate-500'}">${escapeHtml(message)}</td></tr>`;
}
Expand Down
82 changes: 76 additions & 6 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,21 @@
#table-body.table-content-enter{animation:tableRefresh .22s ease-out}
@media (prefers-reduced-motion:reduce){#table-body.table-content-enter{animation:none}main.space-y-6 > #selected-tabs-panel{transition:none}main.space-y-6 > #blacklist-panel{transition:none}}
</style>
<style id="catalog-summary-styles">
#app-subtitle:empty { display: none; }
#ticker-count { cursor: help; font-family: inherit; }
#ticker-count:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
#app-summary[hidden] { display: none; }
#app-summary {
box-sizing: border-box; position: fixed; z-index: 100; display: block;
width: min(42rem, calc(100vw - 2rem)); max-height: min(70vh, 32rem);
overflow: auto; overflow-wrap: anywhere; padding: 1rem;
border: 1px solid #cbd5e1; border-radius: .75rem;
background: #fff; color: #334155; font-size: .875rem; line-height: 1.5;
box-shadow: 0 12px 30px rgb(15 23 42 / .2);
}
.dark #app-summary { background: #0f172a; color: #cbd5e1; border-color: #475569; }
</style>
</head>
<body class="min-h-screen font-sans bg-slate-50 text-slate-900 dark:bg-slate-900 dark:text-slate-100 transition-colors duration-300">
<div class="min-h-screen flex flex-col animate-fade-in">
Expand All @@ -163,15 +178,16 @@
</svg>
<h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-white leading-tight">Invesco ETFs</h1>
</div>
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400">
Search Invesco ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
<a href="./api/invesco/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/invesco/index.json</a> generated from
<a href="https://www.invesco.com/us/en/financial-products/etfs.html" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">invesco.com ETF downloads</a> + Yahoo Finance
</span>
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400"></span>
</div>
<div class="flex items-center gap-3 shrink-0">
<span id="ticker-count" class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-semibold px-3.5 py-1.5 rounded-full border border-blue-200 dark:border-blue-500/30">
<button type="button" aria-controls="app-summary" aria-expanded="false" aria-label="ETF catalog information" id="ticker-count" class="bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-sm font-semibold px-3.5 py-1.5 rounded-full border border-blue-200 dark:border-blue-500/30">
Loading…
</button>
<span id="app-summary" role="region" aria-label="ETF catalog information" hidden>
Search Invesco ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
<a href="./api/invesco/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/invesco/index.json</a> generated from
<a href="https://www.invesco.com/us/en/financial-products/etfs.html" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">invesco.com ETF downloads</a> + Yahoo Finance
</span>
<button id="theme-toggle" class="p-1.5 rounded-lg bg-slate-100 dark:bg-slate-800 border border-slate-200 dark:border-slate-700 hover:bg-slate-200 dark:hover:bg-slate-700 transition text-base leading-none" title="Toggle Theme">🌙</button>
</div>
Expand Down Expand Up @@ -272,5 +288,59 @@ <h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-whi
</div>

<script type="text/babel" data-presets="typescript" src="./app.tsx"></script>
<script id="catalog-summary-behavior">
(() => {
const trigger = document.getElementById('ticker-count');
const panel = document.getElementById('app-summary');
if (!trigger || !panel) return;
let pinned = false;
let closeTimer;
const position = () => {
const rect = trigger.getBoundingClientRect();
panel.style.left = `${Math.max(16, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 16))}px`;
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 8, innerHeight - panel.offsetHeight - 16))}px`;
};
const show = () => {
clearTimeout(closeTimer);
panel.hidden = false;
trigger.setAttribute('aria-expanded', 'true');
position();
};
const hide = () => {
clearTimeout(closeTimer);
panel.hidden = true;
trigger.setAttribute('aria-expanded', 'false');
};
const scheduleHide = () => {
clearTimeout(closeTimer);
closeTimer = setTimeout(() => {
const focused = document.activeElement;
if (!pinned && !trigger.matches(':hover') && !panel.matches(':hover') &&
focused !== trigger && !panel.contains(focused)) hide();
}, 150);
};
trigger.addEventListener('pointerenter', event => { if (event.pointerType !== 'touch') show(); });
panel.addEventListener('pointerenter', () => clearTimeout(closeTimer));
trigger.addEventListener('pointerleave', scheduleHide);
panel.addEventListener('pointerleave', scheduleHide);
trigger.addEventListener('focus', show);
panel.addEventListener('focusin', show);
trigger.addEventListener('blur', scheduleHide);
panel.addEventListener('focusout', scheduleHide);
trigger.addEventListener('click', () => { pinned = !pinned; if (pinned) show(); else hide(); });
document.addEventListener('keydown', event => {
if (event.key !== 'Escape') return;
pinned = false;
if (panel.contains(document.activeElement)) trigger.focus();
hide();
});
document.addEventListener('pointerdown', event => {
if (!trigger.contains(event.target) && !panel.contains(event.target)) { pinned = false; hide(); }
});
addEventListener('resize', () => { if (!panel.hidden) position(); });
addEventListener('scroll', () => { if (!panel.hidden) position(); }, { passive: true });
if (typeof ResizeObserver === 'function') new ResizeObserver(() => { if (!panel.hidden) position(); }).observe(panel);
})();
</script>
</body>
</html>
57 changes: 57 additions & 0 deletions scripts/update-data.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,60 @@ frequencyLabelTest('Frequency placeholders display None and existing cadence lab
['Annually', '12 - Annually'], ['Irregular', '99 - Irregular'],
]) frequencyLabelExpect(format(input)).toBe(expected);
});


import { test as headerTest, expect as headerExpect } from 'bun:test';
async function headerSummaryHarness() {
const source = await Bun.file(new URL('../app.tsx', import.meta.url)).text();
const match = /^([ \t]*)function renderHeaderSummary\(/m.exec(source);
headerExpect(match).not.toBeNull();
const tail = source.slice(match!.index);
const end = new RegExp('^' + match![1] + '}', 'm').exec(tail)!;
const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(tail.slice(0, end.index + end[0].length));
const makeNode = (text = ''): any => {
const node: any = { textContent: text, childNodes: [], dataset: {}, listeners: {} };
node.replaceChildren = (...children: any[]) => { node.childNodes = children; };
node.append = (...children: any[]) => { node.childNodes.push(...children); };
node.addEventListener = (name: string, listener: any) => { node.listeners[name] = listener; };
return node;
};
const panel = makeNode(), subtitle = makeNode(), details = makeNode('Data: source link and updated timestamp');
subtitle.append(details);
const document = { getElementById: () => panel, createTextNode: makeNode, createElement: () => makeNode() };
const render = new Function('document', js + '; return renderHeaderSummary;')(document);
const text = () => subtitle.childNodes.map((n: any) => n.textContent).join('');
return { render, panel, subtitle, details, makeNode, text };
}
headerTest('header has no visible subtitle without selection; original details nodes are retained', async () => {
const h = await headerSummaryHarness();
h.render(h.subtitle, new Set(), null, () => {});
headerExpect(h.text()).toBe('');
headerExpect(h.panel.childNodes).toEqual([h.details]);
headerExpect(h.panel.childNodes[0]).toBe(h.details);
});
headerTest('header shows sorted selected tickers only, preserving click activation and highlight', async () => {
const h = await headerSummaryHarness(); const activated: string[] = [];
h.render(h.subtitle, new Set(['ZZZ', 'AAA']), 'AAA', (ticker: string) => activated.push(ticker));
headerExpect(h.text()).toBe('2 selected: AAA, ZZZ');
const links = h.subtitle.childNodes.filter((n: any) => n.dataset.headerFund);
headerExpect(links[0].className).toContain('underline');
links[1].listeners.click({ preventDefault() {} });
headerExpect(activated).toEqual(['ZZZ']);
headerExpect(h.panel.childNodes[0]).toBe(h.details);
});
headerTest('all selected still lists tickers; clear replaces both summary and selection', async () => {
const h = await headerSummaryHarness();
h.render(h.subtitle, new Set(['CCC','AAA','BBB']), 'BBB', () => {});
headerExpect(h.text()).toBe('3 selected: AAA, BBB, CCC');
const next = h.makeNode('Fresh detail context'); h.subtitle.replaceChildren(next);
h.render(h.subtitle, new Set(), null, () => {});
headerExpect(h.text()).toBe(''); headerExpect(h.panel.childNodes).toEqual([next]);
});
headerTest('header markup supplies a focusable counter and hidden rich panel with dismissal', async () => {
const html = await Bun.file(new URL('../index.html', import.meta.url)).text();
headerExpect(html).toMatch(/<button[^>]*aria-controls="app-summary"[^>]*id="ticker-count"/);
headerExpect(html).toContain('id="app-summary" role="region" aria-label="ETF catalog information" hidden');
headerExpect(html).toContain("event.key !== 'Escape'");
headerExpect(html).toContain("trigger.addEventListener('focus', show)");
headerExpect(html).toContain("trigger.addEventListener('pointerenter'");
});