mirror of
https://github.com/LucasKalil-Programador/world-2026-hub.git
synced 2026-07-04 17:41:28 -03:00
fix(header): two-row layout with scrollable tabs below 1100px
This commit is contained in:
parent
6e33142c96
commit
ad6d7ea616
4 changed files with 95 additions and 4 deletions
|
|
@ -36,7 +36,8 @@ worldcup2026/
|
||||||
│ │ (hover-scale/glow, pulse, line-draw)
|
│ │ (hover-scale/glow, pulse, line-draw)
|
||||||
│ ├── js/
|
│ ├── js/
|
||||||
│ │ ├── app.js ★ Entry point: loadData() (Promise.all over data/),
|
│ │ ├── app.js ★ Entry point: loadData() (Promise.all over data/),
|
||||||
│ │ │ tab routing + lastTab, formatMatchTime(), dashboard,
|
│ │ │ tab routing + lastTab (active-tab scroll-into-view +
|
||||||
|
│ │ │ edge fades on the scrollable nav), formatMatchTime(), dashboard,
|
||||||
│ │ │ clock-driven hero (matchState/findFeaturedMatches +
|
│ │ │ clock-driven hero (matchState/findFeaturedMatches +
|
||||||
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h window; stacks
|
│ │ │ 1s heroTick: hybrid JSON+clock, 2h/3h window; stacks
|
||||||
│ │ │ simultaneous group-final matches, one shared timer)
|
│ │ │ simultaneous group-final matches, one shared timer)
|
||||||
|
|
|
||||||
|
|
@ -213,6 +213,14 @@ Static web app showing the FIFA World Cup 2026 (Mexico/USA/Canada, 48 teams) —
|
||||||
- **Gotchas conhecidos:** (a) a hora compartilhada usa o estádio de `featured[0]` no modo "hora do estádio" — os pares reais são do mesmo fuso, então fica correto; (b) se um dos dois for marcado `finished` no JSON **antes** da janela do slot fechar, ele vira `over` e **sai** do conjunto (o hero mostraria só o outro até a janela acabar) — não ocorre na prática porque o update diário é feito depois que ambos já encerraram pelo relógio.
|
- **Gotchas conhecidos:** (a) a hora compartilhada usa o estádio de `featured[0]` no modo "hora do estádio" — os pares reais são do mesmo fuso, então fica correto; (b) se um dos dois for marcado `finished` no JSON **antes** da janela do slot fechar, ele vira `over` e **sai** do conjunto (o hero mostraria só o outro até a janela acabar) — não ocorre na prática porque o update diário é feito depois que ambos já encerraram pelo relógio.
|
||||||
- **Verificado (preview, relógio fakeado):** real-now → 1 jogo idêntico (ESP-CPV, "Bola rolando!"); 24/06 18:30Z → 2 confrontos (SUI×CAN BC Place / BIH×QAT Lumen Field), "Próximas partidas Grupo B", hora compartilhada, 1 divisória, 1 countdown, 4 bandeiras; 19:30Z → ambos "Bola rolando!"/vs sem countdown; restauro do relógio volta ao hero de 1 jogo sem resíduo; EN "Next matches Group B"; mobile 375px empilha certo; console limpo. Screenshots desktop+mobile conferidos.
|
- **Verificado (preview, relógio fakeado):** real-now → 1 jogo idêntico (ESP-CPV, "Bola rolando!"); 24/06 18:30Z → 2 confrontos (SUI×CAN BC Place / BIH×QAT Lumen Field), "Próximas partidas Grupo B", hora compartilhada, 1 divisória, 1 countdown, 4 bandeiras; 19:30Z → ambos "Bola rolando!"/vs sem countdown; restauro do relógio volta ao hero de 1 jogo sem resíduo; EN "Next matches Group B"; mobile 375px empilha certo; console limpo. Screenshots desktop+mobile conferidos.
|
||||||
|
|
||||||
|
### Header responsivo — 2 faixas + abas roláveis (2026-06-15)
|
||||||
|
- **Problema:** o header (logo + 6 abas + controles hora/idioma) tentava virar linha única já a partir de **768px**, mas só cabe com ~950px de conteúdo → entre 768–~1100px os controles vazavam pra uma 2ª linha quebrada; no estreito a faixa de abas (scroll-x, scrollbar escondida) cortava a aba ativa sem pista (parecia bug).
|
||||||
|
- **Solução (CSS):** o flip pra linha única (`.tabs { flex:0 1 auto; order:0; margin-inline:auto }`) subiu de `@media (min-width:768px)` → **`@media (min-width:1100px)`**. Abaixo disso vale o base mobile-first = **2 faixas estáveis**: faixa 1 = logo + controles (`margin-left:auto`), faixa 2 = abas (`flex:1 1 100%; order:3`, scroll-x). `.logo` e `.header-controls` ganharam `flex-shrink:0`. Breakpoint medido no preview: container `min(1200px,100%−2rem)`; single-row precisa ~950px (logo 166 + abas 561 PT + controles 191 + gaps) → 1100 dá ~118px de folga (1099=2 faixas/98px, 1100=1 linha/59px, confirmado).
|
||||||
|
- **Fade nas bordas das abas:** `.tabs.fade-left`/`.fade-right` aplicam `mask-image` (gradiente 28px), ligadas/desligadas por JS (`updateTabFades` em `app.js`) só do lado com aba pra rolar (scrollWidth/scrollLeft/clientWidth). Some sem overflow.
|
||||||
|
- **Aba ativa sempre visível:** `scrollActiveTabIntoView(smooth)` centraliza a aba ativa via `scrollLeft` (cálculo por getBoundingClientRect — **sem** `scrollIntoView`, pra não rolar a página). Chamada em `activateTab` (smooth) e em load/resize/langchange (instantâneo); listeners (scroll passivo, resize rAF, langchange) montados em `initTabs`.
|
||||||
|
- **Botão de hora vira ícone no estreito:** `syncTimeToggle` agora monta `<span.time-icon>🕐</span><span.time-label>…</span>`; `@media (max-width:420px) .time-label{display:none}` → só o relógio, logo+controles cabem numa faixa até ~360px. A11y intacta (nome acessível vem de `data-i18n-aria="time.toggleAria"`, não do texto). `.control-btn` virou `inline-flex`. **Nota:** isso supera a linha "768–1439 single-row header" da entrada "Responsive/a11y decisions (2026-06-12)".
|
||||||
|
- **Verificado (preview, eval-geometry acima da largura nativa + screenshot mobile):** 375px→2 faixas, hora só ícone, fade-right, logo+controles juntos; 900px (zona antiga quebrada)→2 faixas estáveis, controles não vazam, "Hora local" completo; 1099→2 faixas; 1100→1 linha centrada; clicar Estatísticas rola a faixa até o fim + troca pra fade-left com a aba 100% visível; console limpo.
|
||||||
|
|
||||||
### How to update real-world data (scores, schedule)
|
### How to update real-world data (scores, schedule)
|
||||||
Follow `how-refresh-data.md` (project root). In short:
|
Follow `how-refresh-data.md` (project root). In short:
|
||||||
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
1. Edit `data/results.json` (scores/status) or `data/matches.json` (schedule, rare).
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,7 @@ button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.55rem;
|
gap: 0.55rem;
|
||||||
|
flex-shrink: 0;
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 1.05rem;
|
font-size: 1.05rem;
|
||||||
|
|
@ -159,6 +160,22 @@ button {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* edge fades — applied by JS only on a side that has more tabs to scroll to */
|
||||||
|
.tabs.fade-right {
|
||||||
|
-webkit-mask-image: linear-gradient(to left, transparent 0, #000 28px);
|
||||||
|
mask-image: linear-gradient(to left, transparent 0, #000 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs.fade-left {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px);
|
||||||
|
mask-image: linear-gradient(to right, transparent 0, #000 28px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs.fade-left.fade-right {
|
||||||
|
-webkit-mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||||
|
mask-image: linear-gradient(to right, transparent 0, #000 28px, #000 calc(100% - 28px), transparent 100%);
|
||||||
|
}
|
||||||
|
|
||||||
.tab-btn {
|
.tab-btn {
|
||||||
padding: 0.5rem 0.95rem;
|
padding: 0.5rem 0.95rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
|
|
@ -183,9 +200,13 @@ button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-btn {
|
.control-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.35rem;
|
||||||
padding: 0.35rem 0.8rem;
|
padding: 0.35rem 0.8rem;
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
|
@ -195,6 +216,10 @@ button {
|
||||||
transition: color 0.2s, border-color 0.2s;
|
transition: color 0.2s, border-color 0.2s;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-icon {
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.control-btn:hover {
|
.control-btn:hover {
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border-color: var(--accent-gold);
|
border-color: var(--accent-gold);
|
||||||
|
|
@ -1048,8 +1073,22 @@ dialog.match-modal::backdrop {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* tablet 768–1439px: single-row header with centered (reduced) menu */
|
/* very narrow: collapse the time toggle to its icon so logo + controls stay on
|
||||||
@media (min-width: 768px) {
|
one row and the tab strip gets its own full-width scrollable row below */
|
||||||
|
@media (max-width: 420px) {
|
||||||
|
.time-label {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.control-btn {
|
||||||
|
padding: 0.35rem 0.55rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* single-row header only once logo + 6 tabs + controls genuinely fit (~950px of
|
||||||
|
content + container padding); below this the tabs keep their own scroll row,
|
||||||
|
so the controls never wrap into a broken second line. */
|
||||||
|
@media (min-width: 1100px) {
|
||||||
.tabs {
|
.tabs {
|
||||||
flex: 0 1 auto;
|
flex: 0 1 auto;
|
||||||
order: 0;
|
order: 0;
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,7 @@ function activateTab(id, { updateHash = true } = {}) {
|
||||||
}
|
}
|
||||||
setPref('lastTab', tab);
|
setPref('lastTab', tab);
|
||||||
if (updateHash) history.replaceState(null, '', `#${tab}`);
|
if (updateHash) history.replaceState(null, '', `#${tab}`);
|
||||||
|
scrollActiveTabIntoView(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// programmatic navigation for cross-view links (e.g. stadium → its matches)
|
// programmatic navigation for cross-view links (e.g. stadium → its matches)
|
||||||
|
|
@ -101,7 +102,46 @@ function initTabs() {
|
||||||
});
|
});
|
||||||
window.addEventListener('hashchange', () =>
|
window.addEventListener('hashchange', () =>
|
||||||
activateTab(location.hash.slice(1), { updateHash: false }));
|
activateTab(location.hash.slice(1), { updateHash: false }));
|
||||||
|
|
||||||
|
// edge fades + keep the active tab visible while the nav scrolls horizontally
|
||||||
|
// (below the 1100px single-row breakpoint the tab strip is a scroll container)
|
||||||
|
const tabsEl = document.querySelector('.tabs');
|
||||||
|
tabsEl.addEventListener('scroll', updateTabFades, { passive: true });
|
||||||
|
let resizeRaf = 0;
|
||||||
|
window.addEventListener('resize', () => {
|
||||||
|
cancelAnimationFrame(resizeRaf);
|
||||||
|
resizeRaf = requestAnimationFrame(() => { scrollActiveTabIntoView(false); updateTabFades(); });
|
||||||
|
});
|
||||||
|
// language toggle changes label widths → re-measure overflow and recenter
|
||||||
|
document.addEventListener('langchange', () => { scrollActiveTabIntoView(false); updateTabFades(); });
|
||||||
|
|
||||||
activateTab(location.hash.slice(1) || getPrefs().lastTab || 'home');
|
activateTab(location.hash.slice(1) || getPrefs().lastTab || 'home');
|
||||||
|
updateTabFades();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Toggle edge-fade masks on the tab strip: a fade only shows on a side that has
|
||||||
|
// more tabs to scroll toward, so the cut-off tab no longer looks like a bug.
|
||||||
|
function updateTabFades() {
|
||||||
|
const tabs = document.querySelector('.tabs');
|
||||||
|
if (!tabs) return;
|
||||||
|
const overflowing = tabs.scrollWidth - tabs.clientWidth > 1;
|
||||||
|
const atStart = tabs.scrollLeft <= 1;
|
||||||
|
const atEnd = tabs.scrollLeft >= tabs.scrollWidth - tabs.clientWidth - 1;
|
||||||
|
tabs.classList.toggle('fade-left', overflowing && !atStart);
|
||||||
|
tabs.classList.toggle('fade-right', overflowing && !atEnd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Horizontally scroll the active tab to the center of the strip (no page jump).
|
||||||
|
function scrollActiveTabIntoView(smooth) {
|
||||||
|
const tabs = document.querySelector('.tabs');
|
||||||
|
if (!tabs) return;
|
||||||
|
const active = tabs.querySelector('.tab-btn.active');
|
||||||
|
if (!active || tabs.scrollWidth <= tabs.clientWidth) { updateTabFades(); return; }
|
||||||
|
const tabsRect = tabs.getBoundingClientRect();
|
||||||
|
const aRect = active.getBoundingClientRect();
|
||||||
|
const target = tabs.scrollLeft + (aRect.left - tabsRect.left) - (tabs.clientWidth - aRect.width) / 2;
|
||||||
|
tabs.scrollTo({ left: Math.max(0, target), behavior: smooth ? 'smooth' : 'auto' });
|
||||||
|
requestAnimationFrame(updateTabFades);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- hero
|
// ---------------------------------------------------------------- hero
|
||||||
|
|
@ -367,7 +407,10 @@ function initFavorites() {
|
||||||
function syncTimeToggle() {
|
function syncTimeToggle() {
|
||||||
const btn = document.getElementById('time-toggle');
|
const btn = document.getElementById('time-toggle');
|
||||||
const mode = getPrefs().timeMode ?? 'local';
|
const mode = getPrefs().timeMode ?? 'local';
|
||||||
btn.textContent = `🕐 ${t(mode === 'local' ? 'time.local' : 'time.stadium')}`;
|
// icon + label split so the label can collapse on narrow screens (the
|
||||||
|
// accessible name comes from data-i18n-aria, so hiding the text is a11y-safe).
|
||||||
|
btn.innerHTML = `<span class="time-icon" aria-hidden="true">🕐</span>` +
|
||||||
|
`<span class="time-label">${t(mode === 'local' ? 'time.local' : 'time.stadium')}</span>`;
|
||||||
btn.setAttribute('aria-pressed', String(mode === 'stadium'));
|
btn.setAttribute('aria-pressed', String(mode === 'stadium'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue