Skip to content

Commit a2be20e

Browse files
feat: move header details into ETF count popover
Co-authored-by: Arena Agent <arena-agent@users.noreply.github.com>
1 parent 30414b1 commit a2be20e

3 files changed

Lines changed: 161 additions & 7 deletions

File tree

‎app.tsx‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1608,7 +1608,29 @@ function renderDistributionsTable(fund: FundRow): void {
16081608
// 7. Subtitle
16091609
// =========================================================================
16101610

1611-
function renderSubtitle(text?: string): void {
1611+
function renderHeaderSummary(subtitle: HTMLElement, tickers: Iterable<string>, activeTicker: string | null, activate: (ticker: string) => void): void {
1612+
const panel = document.getElementById('app-summary');
1613+
if (!panel) return;
1614+
// Move existing nodes: provenance links and their handlers remain intact.
1615+
panel.replaceChildren(...Array.from(subtitle.childNodes));
1616+
subtitle.replaceChildren();
1617+
const selected = [...tickers].sort();
1618+
if (!selected.length) return;
1619+
subtitle.append(document.createTextNode(`${selected.length} selected: `));
1620+
selected.forEach((ticker, index) => {
1621+
if (index) subtitle.append(document.createTextNode(', '));
1622+
const link = document.createElement('a');
1623+
link.href = '#';
1624+
link.dataset.headerFund = ticker;
1625+
link.title = `View ${ticker} details`;
1626+
link.className = `font-semibold ${ticker === activeTicker ? 'text-blue-700 dark:text-blue-300 underline' : 'text-blue-600 dark:text-blue-400 hover:underline'}`;
1627+
link.textContent = ticker;
1628+
link.addEventListener('click', event => { event.preventDefault(); activate(ticker); });
1629+
subtitle.append(link);
1630+
});
1631+
}
1632+
1633+
function renderSubtitleDetails(text?: string): void {
16121634
const generated = state.generatedAt ? new Date(state.generatedAt).toLocaleString() : '';
16131635
const countsText = state.counts
16141636
? `${state.counts.funds} ETFs · ${(state.counts.holdings || 0).toLocaleString('en-US')} holdings rows · ${(state.counts.history || 0).toLocaleString('en-US')} history rows`
@@ -1639,6 +1661,11 @@ function renderSubtitle(text?: string): void {
16391661
});
16401662
}
16411663

1664+
function renderSubtitle(text?: string): void {
1665+
renderSubtitleDetails(text);
1666+
renderHeaderSummary(el.subtitle, state.selected, state.activeFundTicker, activateFund);
1667+
}
1668+
16421669
function setStatusRow(message: string, tone: 'info' | 'error'): void {
16431670
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>`;
16441671
}

‎index.html‎

Lines changed: 76 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,21 @@
139139
#table-body.table-content-enter{animation:tableRefresh .22s ease-out}
140140
@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}}
141141
</style>
142+
<style id="catalog-summary-styles">
143+
#app-subtitle:empty { display: none; }
144+
#ticker-count { cursor: help; font-family: inherit; }
145+
#ticker-count:focus-visible { outline: 2px solid #3b82f6; outline-offset: 3px; }
146+
#app-summary[hidden] { display: none; }
147+
#app-summary {
148+
box-sizing: border-box; position: fixed; z-index: 100; display: block;
149+
width: min(42rem, calc(100vw - 2rem)); max-height: min(70vh, 32rem);
150+
overflow: auto; overflow-wrap: anywhere; padding: 1rem;
151+
border: 1px solid #cbd5e1; border-radius: .75rem;
152+
background: #fff; color: #334155; font-size: .875rem; line-height: 1.5;
153+
box-shadow: 0 12px 30px rgb(15 23 42 / .2);
154+
}
155+
.dark #app-summary { background: #0f172a; color: #cbd5e1; border-color: #475569; }
156+
</style>
142157
</head>
143158
<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">
144159
<div class="min-h-screen flex flex-col animate-fade-in">
@@ -153,15 +168,16 @@
153168
</svg>
154169
<h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-white leading-tight">WisdomTree ETFs</h1>
155170
</div>
156-
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400">
157-
Search WisdomTree ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
158-
<a href="./api/wisdomtree/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/wisdomtree/index.json</a> generated from
159-
<a href="https://www.wisdomtree.com/us/products" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">official WisdomTree product pages</a> + SEC EDGAR N-PORT-P + Yahoo Finance
160-
</span>
171+
<span id="app-subtitle" class="basis-full sm:basis-auto text-sm text-slate-500 dark:text-slate-400"></span>
161172
</div>
162173
<div class="flex items-center gap-3 shrink-0">
163-
<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">
174+
<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">
164175
Loading…
176+
</button>
177+
<span id="app-summary" role="region" aria-label="ETF catalog information" hidden>
178+
Search WisdomTree ETFs, select ETFs via the “Use” checkbox, then use the Watchlist tab. Data comes from
179+
<a href="./api/wisdomtree/index.json" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">api/wisdomtree/index.json</a> generated from
180+
<a href="https://www.wisdomtree.com/us/products" target="_blank" rel="noopener noreferrer" class="font-semibold text-blue-600 dark:text-blue-400 hover:underline">official WisdomTree product pages</a> + SEC EDGAR N-PORT-P + Yahoo Finance
165181
</span>
166182
<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>
167183
</div>
@@ -263,5 +279,59 @@ <h1 class="min-w-0 text-xl font-bold tracking-tight text-slate-900 dark:text-whi
263279
</div>
264280

265281
<script type="text/babel" data-presets="typescript" src="./app.tsx"></script>
282+
<script id="catalog-summary-behavior">
283+
(() => {
284+
const trigger = document.getElementById('ticker-count');
285+
const panel = document.getElementById('app-summary');
286+
if (!trigger || !panel) return;
287+
let pinned = false;
288+
let closeTimer;
289+
const position = () => {
290+
const rect = trigger.getBoundingClientRect();
291+
panel.style.left = `${Math.max(16, Math.min(rect.right - panel.offsetWidth, innerWidth - panel.offsetWidth - 16))}px`;
292+
panel.style.top = `${Math.max(16, Math.min(rect.bottom + 8, innerHeight - panel.offsetHeight - 16))}px`;
293+
};
294+
const show = () => {
295+
clearTimeout(closeTimer);
296+
panel.hidden = false;
297+
trigger.setAttribute('aria-expanded', 'true');
298+
position();
299+
};
300+
const hide = () => {
301+
clearTimeout(closeTimer);
302+
panel.hidden = true;
303+
trigger.setAttribute('aria-expanded', 'false');
304+
};
305+
const scheduleHide = () => {
306+
clearTimeout(closeTimer);
307+
closeTimer = setTimeout(() => {
308+
const focused = document.activeElement;
309+
if (!pinned && !trigger.matches(':hover') && !panel.matches(':hover') &&
310+
focused !== trigger && !panel.contains(focused)) hide();
311+
}, 150);
312+
};
313+
trigger.addEventListener('pointerenter', event => { if (event.pointerType !== 'touch') show(); });
314+
panel.addEventListener('pointerenter', () => clearTimeout(closeTimer));
315+
trigger.addEventListener('pointerleave', scheduleHide);
316+
panel.addEventListener('pointerleave', scheduleHide);
317+
trigger.addEventListener('focus', show);
318+
panel.addEventListener('focusin', show);
319+
trigger.addEventListener('blur', scheduleHide);
320+
panel.addEventListener('focusout', scheduleHide);
321+
trigger.addEventListener('click', () => { pinned = !pinned; if (pinned) show(); else hide(); });
322+
document.addEventListener('keydown', event => {
323+
if (event.key !== 'Escape') return;
324+
pinned = false;
325+
if (panel.contains(document.activeElement)) trigger.focus();
326+
hide();
327+
});
328+
document.addEventListener('pointerdown', event => {
329+
if (!trigger.contains(event.target) && !panel.contains(event.target)) { pinned = false; hide(); }
330+
});
331+
addEventListener('resize', () => { if (!panel.hidden) position(); });
332+
addEventListener('scroll', () => { if (!panel.hidden) position(); }, { passive: true });
333+
if (typeof ResizeObserver === 'function') new ResizeObserver(() => { if (!panel.hidden) position(); }).observe(panel);
334+
})();
335+
</script>
266336
</body>
267337
</html>

‎scripts/update-data.test.ts‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,3 +314,60 @@ queueDescribe('per-ticker queue preserves caller results and stores completion-o
314314
queueExpect(await chains.get('SGOL')).toBeUndefined();
315315
});
316316
});
317+
318+
319+
import { test as headerTest, expect as headerExpect } from 'bun:test';
320+
async function headerSummaryHarness() {
321+
const source = await Bun.file(new URL('../app.tsx', import.meta.url)).text();
322+
const match = /^([ \t]*)function renderHeaderSummary\(/m.exec(source);
323+
headerExpect(match).not.toBeNull();
324+
const tail = source.slice(match!.index);
325+
const end = new RegExp('^' + match![1] + '}', 'm').exec(tail)!;
326+
const js = new Bun.Transpiler({ loader: 'ts' }).transformSync(tail.slice(0, end.index + end[0].length));
327+
const makeNode = (text = ''): any => {
328+
const node: any = { textContent: text, childNodes: [], dataset: {}, listeners: {} };
329+
node.replaceChildren = (...children: any[]) => { node.childNodes = children; };
330+
node.append = (...children: any[]) => { node.childNodes.push(...children); };
331+
node.addEventListener = (name: string, listener: any) => { node.listeners[name] = listener; };
332+
return node;
333+
};
334+
const panel = makeNode(), subtitle = makeNode(), details = makeNode('Data: source link and updated timestamp');
335+
subtitle.append(details);
336+
const document = { getElementById: () => panel, createTextNode: makeNode, createElement: () => makeNode() };
337+
const render = new Function('document', js + '; return renderHeaderSummary;')(document);
338+
const text = () => subtitle.childNodes.map((n: any) => n.textContent).join('');
339+
return { render, panel, subtitle, details, makeNode, text };
340+
}
341+
headerTest('header has no visible subtitle without selection; original details nodes are retained', async () => {
342+
const h = await headerSummaryHarness();
343+
h.render(h.subtitle, new Set(), null, () => {});
344+
headerExpect(h.text()).toBe('');
345+
headerExpect(h.panel.childNodes).toEqual([h.details]);
346+
headerExpect(h.panel.childNodes[0]).toBe(h.details);
347+
});
348+
headerTest('header shows sorted selected tickers only, preserving click activation and highlight', async () => {
349+
const h = await headerSummaryHarness(); const activated: string[] = [];
350+
h.render(h.subtitle, new Set(['ZZZ', 'AAA']), 'AAA', (ticker: string) => activated.push(ticker));
351+
headerExpect(h.text()).toBe('2 selected: AAA, ZZZ');
352+
const links = h.subtitle.childNodes.filter((n: any) => n.dataset.headerFund);
353+
headerExpect(links[0].className).toContain('underline');
354+
links[1].listeners.click({ preventDefault() {} });
355+
headerExpect(activated).toEqual(['ZZZ']);
356+
headerExpect(h.panel.childNodes[0]).toBe(h.details);
357+
});
358+
headerTest('all selected still lists tickers; clear replaces both summary and selection', async () => {
359+
const h = await headerSummaryHarness();
360+
h.render(h.subtitle, new Set(['CCC','AAA','BBB']), 'BBB', () => {});
361+
headerExpect(h.text()).toBe('3 selected: AAA, BBB, CCC');
362+
const next = h.makeNode('Fresh detail context'); h.subtitle.replaceChildren(next);
363+
h.render(h.subtitle, new Set(), null, () => {});
364+
headerExpect(h.text()).toBe(''); headerExpect(h.panel.childNodes).toEqual([next]);
365+
});
366+
headerTest('header markup supplies a focusable counter and hidden rich panel with dismissal', async () => {
367+
const html = await Bun.file(new URL('../index.html', import.meta.url)).text();
368+
headerExpect(html).toMatch(/<button[^>]*aria-controls="app-summary"[^>]*id="ticker-count"/);
369+
headerExpect(html).toContain('id="app-summary" role="region" aria-label="ETF catalog information" hidden');
370+
headerExpect(html).toContain("event.key !== 'Escape'");
371+
headerExpect(html).toContain("trigger.addEventListener('focus', show)");
372+
headerExpect(html).toContain("trigger.addEventListener('pointerenter'");
373+
});

0 commit comments

Comments
 (0)