feat(web): progressive disclosure for dense pages per §16.15
Leaderboard, bot directory, match history, and playlist pages now use expandable rows/cards, IntersectionObserver lazy rendering, windowed virtual scrolling (for 1000+ leaderboard entries), and batched "Show more" affordances. All expand/collapse transitions respect prefers-reduced-motion. Keyboard accessibility (Enter/Space to toggle) and ARIA attributes (aria-expanded, aria-controls) added throughout. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
e90d2e37c9
commit
4a92539c6f
4 changed files with 96 additions and 21 deletions
|
|
@ -6,6 +6,7 @@ import { fetchBotDirectory, type BotDirectoryEntry } from '../api-types';
|
|||
import { initLazySections, lazySection } from '../lib/lazy-section';
|
||||
|
||||
const INITIAL_COUNT = 30;
|
||||
const BATCH_SIZE = 30;
|
||||
|
||||
export async function renderBotsPage(): Promise<void> {
|
||||
const app = document.getElementById('app');
|
||||
|
|
@ -65,21 +66,82 @@ function renderBotsList(
|
|||
return;
|
||||
}
|
||||
|
||||
// Use lazySection for below-the-fold bot cards
|
||||
const remainingHtml = remaining.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + i + 1)).join('');
|
||||
// For remaining bots, wrap in a lazy section that shows a "Show more" button
|
||||
// instead of rendering everything at once when revealed
|
||||
container.innerHTML = `
|
||||
<p class="updated-at">Last updated: ${formatTimestamp(updatedAt)}</p>
|
||||
<div class="bots-grid">
|
||||
${initialHtml}
|
||||
${lazySection(
|
||||
'bots-remaining',
|
||||
remainingHtml,
|
||||
{ placeholder: '<div class="lazy-placeholder" style="min-height:200px"></div>' }
|
||||
)}
|
||||
</div>
|
||||
<div class="bots-grid" id="bots-grid">${initialHtml}</div>
|
||||
<div id="bots-remaining-anchor"></div>
|
||||
`;
|
||||
|
||||
initLazySections(container);
|
||||
const grid = document.getElementById('bots-grid')!;
|
||||
const anchor = document.getElementById('bots-remaining-anchor')!;
|
||||
|
||||
// Use IntersectionObserver to trigger "Show more" batching when user scrolls near bottom
|
||||
let offset = 0;
|
||||
const total = remaining.length;
|
||||
|
||||
function updateShowMoreBtn(btn: HTMLButtonElement): void {
|
||||
const left = total - offset;
|
||||
if (left <= 0) { btn.remove(); return; }
|
||||
const next = Math.min(BATCH_SIZE, left);
|
||||
btn.textContent = `Show ${next} more bots (${left} remaining)`;
|
||||
btn.setAttribute('aria-label', `Show ${next} more bots, ${left} remaining`);
|
||||
}
|
||||
|
||||
if (total > BATCH_SIZE) {
|
||||
// Lazy-load with IntersectionObserver + "Show more" button
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (!entry.isIntersecting) continue;
|
||||
observer.disconnect();
|
||||
|
||||
// Render first batch of remaining
|
||||
appendBatch();
|
||||
}
|
||||
}, { rootMargin: '300px' });
|
||||
observer.observe(anchor);
|
||||
|
||||
// Cleanup on navigation
|
||||
window.addEventListener('hashchange', () => observer.disconnect(), { once: true });
|
||||
} else {
|
||||
// Small remainder — render in a lazy section
|
||||
const remainingHtml = remaining.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + i + 1)).join('');
|
||||
anchor.innerHTML = lazySection(
|
||||
'bots-remaining',
|
||||
remainingHtml,
|
||||
{ placeholder: '<div class="lazy-placeholder" style="min-height:200px"></div>' }
|
||||
);
|
||||
initLazySections(anchor);
|
||||
}
|
||||
|
||||
function appendBatch(): void {
|
||||
const batch = remaining.slice(offset, offset + BATCH_SIZE);
|
||||
if (batch.length === 0) return;
|
||||
|
||||
const html = batch.map((bot, i) => renderBotCard(bot, INITIAL_COUNT + offset + i + 1)).join('');
|
||||
const temp = document.createElement('div');
|
||||
temp.innerHTML = html;
|
||||
while (temp.firstChild) {
|
||||
grid.appendChild(temp.firstChild);
|
||||
}
|
||||
|
||||
offset += batch.length;
|
||||
|
||||
if (offset < total) {
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'btn secondary show-more-btn';
|
||||
btn.type = 'button';
|
||||
updateShowMoreBtn(btn);
|
||||
btn.addEventListener('click', () => {
|
||||
btn.remove();
|
||||
appendBatch();
|
||||
});
|
||||
grid.after(btn);
|
||||
} else {
|
||||
anchor.remove();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function renderBotCard(bot: BotDirectoryEntry, rank: number): string {
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ function renderDesktopRow(entry: LeaderboardEntry, _index: number): string {
|
|||
const statusClass = entry.health_status === 'healthy' ? 'status-healthy' :
|
||||
entry.health_status === 'unhealthy' ? 'status-unhealthy' : 'status-unknown';
|
||||
return `
|
||||
<div class="lb-row ${rankClass}" data-bot-id="${encodeURIComponent(entry.bot_id)}">
|
||||
<div class="lb-row ${rankClass}" data-bot-id="${encodeURIComponent(entry.bot_id)}" tabindex="0" role="button" aria-expanded="false">
|
||||
<span class="lb-rank">${entry.rank}</span>
|
||||
<span class="lb-name">
|
||||
<a href="#/bot/${encodeURIComponent(entry.bot_id)}">${escapeHtml(entry.name)}</a>
|
||||
|
|
@ -173,11 +173,24 @@ function initDesktopExpandToggle(container: HTMLElement): void {
|
|||
const row = (e.target as HTMLElement).closest('.lb-row') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
if ((e.target as HTMLElement).closest('a, button')) return;
|
||||
const expanded = row.classList.toggle('row-expanded');
|
||||
row.setAttribute('aria-expanded', String(expanded));
|
||||
const icon = row.querySelector('.lb-expand-icon');
|
||||
if (icon) icon.textContent = expanded ? '▾' : '▸';
|
||||
toggleRowExpand(row);
|
||||
});
|
||||
|
||||
// Keyboard support for static table rows
|
||||
container.addEventListener('keydown', (e) => {
|
||||
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||
const row = (e.target as HTMLElement).closest('.lb-row') as HTMLElement | null;
|
||||
if (!row) return;
|
||||
e.preventDefault();
|
||||
toggleRowExpand(row);
|
||||
});
|
||||
}
|
||||
|
||||
function toggleRowExpand(row: HTMLElement): void {
|
||||
const expanded = row.classList.toggle('row-expanded');
|
||||
row.setAttribute('aria-expanded', String(expanded));
|
||||
const icon = row.querySelector('.lb-expand-icon');
|
||||
if (icon) icon.textContent = expanded ? '▾' : '▸';
|
||||
}
|
||||
|
||||
// ─── Mobile rendering ───────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -293,7 +293,7 @@ function renderMatchCard(match: MatchSummary): string {
|
|||
|
||||
return `
|
||||
<div class="match-card" data-match-id="${escapeHtml(match.id)}">
|
||||
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false">
|
||||
<button class="match-card-toggle" type="button" aria-label="Expand match details" aria-expanded="false" aria-controls="match-details-${escapeHtml(match.id)}">
|
||||
<div class="match-header">
|
||||
<span class="match-id">${escapeHtml(match.id.slice(0, 8))}</span>
|
||||
<span class="match-time">${completedAt}</span>
|
||||
|
|
@ -311,7 +311,7 @@ function renderMatchCard(match: MatchSummary): string {
|
|||
`).join('')}
|
||||
</div>
|
||||
</button>
|
||||
<div class="match-card-details">
|
||||
<div class="match-card-details" id="match-details-${escapeHtml(match.id)}">
|
||||
<div class="match-footer">
|
||||
<span class="match-turns">${match.turns ?? '-'} turns</span>
|
||||
<span class="match-reason">${match.end_reason ?? '-'}</span>
|
||||
|
|
@ -333,7 +333,6 @@ function initMatchCardToggles(root: HTMLElement): void {
|
|||
const details = card.querySelector<HTMLElement>('.match-card-details');
|
||||
if (!details) return;
|
||||
const expanded = details.classList.toggle('expanded');
|
||||
card.setAttribute('aria-expanded', String(expanded));
|
||||
toggle.setAttribute('aria-expanded', String(expanded));
|
||||
const icon = card.querySelector('.match-expand-icon');
|
||||
if (icon) icon.textContent = expanded ? '▾' : '▸';
|
||||
|
|
|
|||
|
|
@ -440,7 +440,8 @@ function renderPlaylistMatchHtml(m: PlaylistMatch): string {
|
|||
<div class="playlist-match" data-match-id="${m.match_id}">
|
||||
<button class="playlist-match-toggle" type="button"
|
||||
aria-label="Expand details for ${escapeHtml(m.title || `Match ${m.order + 1}`)}"
|
||||
aria-expanded="false">
|
||||
aria-expanded="false"
|
||||
aria-controls="playlist-match-details-${m.match_id}">
|
||||
<span class="match-order">${m.order + 1}</span>
|
||||
<div class="match-info">
|
||||
<div class="match-title">${escapeHtml(m.title || `Match ${m.order + 1}`)}</div>
|
||||
|
|
@ -449,7 +450,7 @@ function renderPlaylistMatchHtml(m: PlaylistMatch): string {
|
|||
</div>
|
||||
<span class="match-expand-icon" aria-hidden="true">▸</span>
|
||||
</button>
|
||||
<div class="playlist-match-details">
|
||||
<div class="playlist-match-details" id="playlist-match-details-${m.match_id}">
|
||||
<div class="playlist-match-actions">
|
||||
<button class="watch-btn" data-match-id="${m.match_id}">Watch</button>
|
||||
<button class="embed-btn" data-match-id="${m.match_id}">Embed</button>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue