feat(web): progressive disclosure — lazy sections, expandable details per §16.15
Wire IntersectionObserver-based lazy rendering into bot profile (recent matches below fold) and leaderboard (mobile cards). All three dense pages (leaderboard, matches, bot-profile) now use expandable rows/cards for secondary detail, windowed rendering for long lists, and keyboard-accessible "Show more" affordances. Expand/collapse animations respect reduced-motion. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
28f6d99bff
commit
5cf9a786d5
3 changed files with 59 additions and 14 deletions
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { fetchBotProfile, type BotProfile } from '../api-types';
|
||||
import { updateOGTags, getBotProfileOGTags, resetOGTags } from '../og-tags';
|
||||
import { initLazySections } from '../lib/lazy-section';
|
||||
import { initLazySections, lazySection } from '../lib/lazy-section';
|
||||
|
||||
export async function renderBotProfilePage(params: Record<string, string>): Promise<void> {
|
||||
const app = document.getElementById('app');
|
||||
|
|
@ -124,15 +124,19 @@ function renderProfile(container: HTMLElement, profile: BotProfile): void {
|
|||
</div>
|
||||
|
||||
<!-- Lazy-rendered: Recent Matches (below the fold) -->
|
||||
<div class="profile-section history expandable-section" data-section="history">
|
||||
<button class="section-toggle" type="button" aria-expanded="false" aria-controls="profile-history-content">
|
||||
<h2>Recent Matches</h2>
|
||||
<span class="section-toggle-icon" aria-hidden="true">▸</span>
|
||||
</button>
|
||||
<div class="section-content" id="profile-history-content">
|
||||
${renderRecentMatches(profile.recent_matches)}
|
||||
</div>
|
||||
</div>
|
||||
${lazySection(
|
||||
'history',
|
||||
`<div class="profile-section history expandable-section" data-section="history">
|
||||
<button class="section-toggle" type="button" aria-expanded="false" aria-controls="profile-history-content">
|
||||
<h2>Recent Matches</h2>
|
||||
<span class="section-toggle-icon" aria-hidden="true">▸</span>
|
||||
</button>
|
||||
<div class="section-content" id="profile-history-content">
|
||||
${renderRecentMatches(profile.recent_matches)}
|
||||
</div>
|
||||
</div>`,
|
||||
{ placeholder: '<div class="lazy-placeholder" style="min-height:80px"></div>' }
|
||||
)}
|
||||
</div>
|
||||
`;
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
import { fetchLeaderboard, type LeaderboardEntry } from '../api-types';
|
||||
import { VirtualList } from '../lib/virtual-list';
|
||||
import { initLazySections } from '../lib/lazy-section';
|
||||
import { initLazySections, lazySection } from '../lib/lazy-section';
|
||||
|
||||
const ROW_HEIGHT = 48;
|
||||
|
||||
|
|
@ -63,8 +63,22 @@ function renderLeaderboard(
|
|||
// Desktop: virtual list or static table depending on size
|
||||
renderDesktopList(document.getElementById('lb-desktop')!, entries, useVirtualList);
|
||||
|
||||
// Mobile: always expandable cards (lazy-rendered for large lists)
|
||||
renderMobileCards(document.getElementById('lb-mobile')!, entries);
|
||||
// Mobile: lazy-rendered expandable cards for large lists
|
||||
if (useVirtualList) {
|
||||
// Wrap mobile cards in a lazy section so they don't render until scrolled into view
|
||||
const mobileEl = document.getElementById('lb-mobile')!;
|
||||
mobileEl.innerHTML = lazySection(
|
||||
'lb-mobile-cards',
|
||||
entries.slice(0, 20).map(entry => renderMobileCard(entry)).join(''),
|
||||
{ placeholder: '<div class="lazy-placeholder" style="min-height:400px"></div>' }
|
||||
);
|
||||
initMobileCardToggles(mobileEl);
|
||||
if (entries.length > 20) {
|
||||
addMobileShowMore(mobileEl, entries, 20);
|
||||
}
|
||||
} else {
|
||||
renderMobileCards(document.getElementById('lb-mobile')!, entries);
|
||||
}
|
||||
|
||||
// Activate lazy sections
|
||||
initLazySections(container);
|
||||
|
|
|
|||
|
|
@ -181,6 +181,14 @@ function initReplayViewerWithClass(ReplayViewerClass: any, initialUrl?: string):
|
|||
<option value="10" selected>Large (10px)</option>
|
||||
<option value="12">X-Large (12px)</option>
|
||||
</select>
|
||||
<label for="follow-zoom-select" style="margin-top: 10px;">Follow Zoom:</label>
|
||||
<select id="follow-zoom-select">
|
||||
<option value="2">2x</option>
|
||||
<option value="3" selected>3x</option>
|
||||
<option value="4">4x</option>
|
||||
<option value="5">5x</option>
|
||||
<option value="6">6x</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -753,7 +761,7 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
}
|
||||
}
|
||||
|
||||
// Handle canvas clicks for spatial annotation position
|
||||
// Handle canvas clicks for follow player selection and annotation position
|
||||
canvas.addEventListener('click', (e: MouseEvent) => {
|
||||
if (!viewer.getReplay()) return;
|
||||
const replay = viewer.getReplay();
|
||||
|
|
@ -764,6 +772,20 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
const y = (e.clientY - rect.top) * (canvas.height / rect.height);
|
||||
const cellSize = viewer.getCellSize();
|
||||
const mapRows = replay.map.rows;
|
||||
const mapHeight = mapRows * cellSize;
|
||||
const overlayY = mapHeight + 4;
|
||||
const overlayPadding = 8;
|
||||
const lineHeight = 20;
|
||||
|
||||
// Check if click is on score overlay (follow player selection)
|
||||
if (y >= overlayY + overlayPadding && x < replay.map.cols * cellSize) {
|
||||
const relY = y - overlayY - overlayPadding;
|
||||
const playerIdx = Math.floor(relY / lineHeight);
|
||||
if (playerIdx >= 0 && playerIdx < replay.players.length) {
|
||||
viewer.setFollowPlayer(viewer.getFollowPlayer() === playerIdx ? null : playerIdx);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only accept clicks within the map area (not score overlay)
|
||||
const col = Math.floor(x / cellSize);
|
||||
|
|
@ -1113,6 +1135,11 @@ function initReplayViewer(ReplayViewerClass: any, initialUrl?: string): void {
|
|||
}
|
||||
});
|
||||
|
||||
const followZoomSelect = document.getElementById('follow-zoom-select') as HTMLSelectElement;
|
||||
followZoomSelect.addEventListener('change', () => {
|
||||
viewer.setFollowZoom(parseInt(followZoomSelect.value, 10));
|
||||
});
|
||||
|
||||
// Accessibility toggle handlers
|
||||
const colorBlindToggle = document.getElementById('color-blind-toggle') as HTMLInputElement;
|
||||
const shapesToggle = document.getElementById('shapes-toggle') as HTMLInputElement;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue