diff --git a/404.html b/404.html index e6a62810..be6aeb9b 100644 --- a/404.html +++ b/404.html @@ -31,6 +31,7 @@ setMetadata('nav', `/${locale}/${language}/nav/nav`); setMetadata('footer', `/${locale}/${language}/footer/footer`); setMetadata('nav-banners', `/${locale}/${language}/nav/nav-banners`); + setMetadata('nav-promos', `/${locale}/${language}/nav/nav-promos`); setMetadata('theme', 'text-center'); diff --git a/blocks/alert-banners/alert-banners.js b/blocks/alert-banners/alert-banners.js index 76821557..c8963606 100644 --- a/blocks/alert-banners/alert-banners.js +++ b/blocks/alert-banners/alert-banners.js @@ -1,424 +1,12 @@ import { parseAlertBanners, - findBestAlertBanner, + findCurrentScheduledItem, currentPastFuture, + formatShortDateTime, + formatDuration, + createTimeline, } from '../../scripts/scripts.js'; -const MONTH_NAMES = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; - -/** - * Formats a Date object into a short date-time string format (M/D HHam/pm). - * @param {Date} date - The date to format - * @returns {string} Formatted date string, or "Invalid Date" or "Open" - */ -function formatShortDateTime(date) { - if (date === null) { - return 'Open'; - } - if (!date || !(date instanceof Date) || Number.isNaN(date.getTime())) { - return 'Invalid Date'; - } - - const month = date.getMonth() + 1; // getMonth() returns 0-11 - const day = date.getDate(); - let hours = date.getHours(); - const minutes = date.getMinutes(); - - // Convert to 12-hour format - const ampm = hours >= 12 ? 'pm' : 'am'; - hours %= 12; - if (hours === 0) hours = 12; // 12am/12pm instead of 0am/0pm - - // Format time - only show minutes if not :00 - let timeStr = `${hours}${ampm}`; - if (minutes !== 0) { - const paddedMinutes = minutes.toString().padStart(2, '0'); - timeStr = `${hours}:${paddedMinutes}${ampm}`; - } - - return `${month}/${day} ${timeStr}`; -} - -/** - * Formats a duration in milliseconds into a human-readable string. - * Shows only the largest rounded unit (weeks for 21+ days, days for 1+ days, etc.) - * @param {number} ms - Duration in milliseconds - * @returns {string} Formatted duration string (e.g., "3d", "2w", "5h") - */ -function formatDuration(ms) { - if (ms === null || Number.isNaN(ms)) return ''; - - const negative = ms < 0; - const absMs = Math.abs(ms); - - const totalMinutes = Math.round(absMs / (1000 * 60)); - const totalHours = totalMinutes / 60; - const totalDays = totalHours / 24; - const totalWeeks = totalDays / 7; - - let value; - let unit; - if (totalDays >= 21) { - // 3+ weeks: show weeks - value = Math.round(totalWeeks); - unit = value === 1 ? 'week' : 'weeks'; - } else if (totalDays >= 1) { - // 1+ days: show days - value = Math.round(totalDays); - unit = value === 1 ? 'day' : 'days'; - } else if (totalHours >= 1) { - // 1+ hours: show hours - value = Math.round(totalHours); - unit = value === 1 ? 'hour' : 'hours'; - } else { - // Less than 1 hour: show minutes - value = Math.round(totalMinutes); - unit = value === 1 ? 'minute' : 'minutes'; - } - - const result = `${value} ${unit}`; - return negative ? `-${result}` : result; -} - -/** - * Creates a timeline visualization showing banners across 6 months. - * Shows 3 months before and 3 months after the current date. - * @param {Array} banners - Array of banner objects - * @param {Object|null} [bestBanner=null] - The optimal banner to highlight - * @param {Date} [date=new Date()] - Reference date for the "now" marker - * @param {Function} [onDateChange=null] - Callback when date is changed via drag (receives newDate) - * @param {Function} [onPreviewChange=null] - Callback when selected banner changes - * @returns {HTMLElement} Timeline container element - */ -function createTimeline( - banners, - bestBanner = null, - date = new Date(), - onDateChange = null, - onPreviewChange = null, -) { - const container = document.createElement('div'); - container.classList.add('alert-banners-timeline'); - - // Calculate 6-month window: 3 months before and 3 months after current date - const currentMonth = date.getMonth(); - const currentYear = date.getFullYear(); - - // Start 3 months before (beginning of that month) - let startMonth = currentMonth - 3; - let startYear = currentYear; - if (startMonth < 0) { - startMonth += 12; - startYear -= 1; - } - const rangeStart = new Date(startYear, startMonth, 1); - - // End 3 months after (end of that month) - let endMonth = currentMonth + 3; - let endYear = currentYear; - if (endMonth > 11) { - endMonth -= 12; - endYear += 1; - } - // Get last day of the end month - const rangeEnd = new Date(endYear, endMonth + 1, 0, 23, 59, 59); - - const rangeDuration = rangeEnd.getTime() - rangeStart.getTime(); - - // Build array of months to display - const displayMonths = []; - let m = startMonth; - let y = startYear; - for (let i = 0; i < 7; i += 1) { - displayMonths.push({ month: m, year: y, name: MONTH_NAMES[m] }); - m += 1; - if (m > 11) { - m = 0; - y += 1; - } - } - - // Create header with months - const header = document.createElement('div'); - header.classList.add('timeline-header'); - - // Show year range or single year - const yearLabel = document.createElement('div'); - yearLabel.classList.add('timeline-year'); - if (startYear === endYear) { - yearLabel.textContent = startYear; - } else { - yearLabel.textContent = `${startYear}–${endYear}`; - } - header.appendChild(yearLabel); - - const monthsRow = document.createElement('div'); - monthsRow.classList.add('timeline-months'); - displayMonths.forEach(({ name }) => { - const monthDiv = document.createElement('div'); - monthDiv.classList.add('timeline-month'); - monthDiv.textContent = name; - monthsRow.appendChild(monthDiv); - }); - header.appendChild(monthsRow); - container.appendChild(header); - - // Create banner rows - const rowsContainer = document.createElement('div'); - rowsContainer.classList.add('timeline-rows'); - - const visibleBanners = []; // Track which banners have visible bars - - banners.forEach((banner) => { - if (!banner.valid) return; - - // Skip banners with negative duration (end before start) - if (banner.start && banner.end && banner.end < banner.start) return; - - // Calculate bar position - const startTime = banner.start ? banner.start.getTime() : rangeStart.getTime(); - const endTime = banner.end ? banner.end.getTime() : rangeEnd.getTime(); - - // Clamp to visible range - const visibleStart = Math.max(startTime, rangeStart.getTime()); - const visibleEnd = Math.min(endTime, rangeEnd.getTime()); - - // Only show row if banner overlaps with the visible range - if (visibleEnd < rangeStart.getTime() || visibleStart > rangeEnd.getTime()) return; - - const row = document.createElement('div'); - row.classList.add('timeline-row'); - - // Track for the bar - const track = document.createElement('div'); - track.classList.add('timeline-track'); - track.title = banner.content?.textContent?.trim() || ''; - - const leftPercent = ((visibleStart - rangeStart.getTime()) / rangeDuration) * 100; - const widthPercent = ((visibleEnd - visibleStart) / rangeDuration) * 100; - - const bar = document.createElement('div'); - bar.classList.add('timeline-bar'); - bar.classList.add(`timeline-bar-${currentPastFuture(banner.start, banner.end, date)}`); - - if (bestBanner === banner) { - bar.classList.add('timeline-bar-selected'); - } - - // Show arrows for open-ended dates - if (!banner.start || banner.start.getTime() < rangeStart.getTime()) { - bar.classList.add('timeline-bar-open-start'); - } - if (!banner.end || banner.end.getTime() > rangeEnd.getTime()) { - bar.classList.add('timeline-bar-open-end'); - } - - bar.style.left = `${Math.max(0, leftPercent)}%`; - bar.style.width = `${Math.min(100, widthPercent)}%`; - - // Tooltip - const startStr = banner.start ? formatShortDateTime(banner.start) : '∞'; - const endStr = banner.end ? formatShortDateTime(banner.end) : '∞'; - bar.title = `${startStr} → ${endStr}`; - - track.appendChild(bar); - visibleBanners.push(banner); // Track this banner has a visible bar - - row.appendChild(track); - rowsContainer.appendChild(row); - }); - - container.appendChild(rowsContainer); - - // Add draggable "now" marker - const nowTime = date.getTime(); - if (nowTime >= rangeStart.getTime() && nowTime <= rangeEnd.getTime()) { - const nowPercent = ((nowTime - rangeStart.getTime()) / rangeDuration) * 100; - const nowMarker = document.createElement('div'); - nowMarker.classList.add('timeline-now'); - nowMarker.title = 'Drag to change date'; - - // Add date label (M/D format) - const dateLabel = document.createElement('div'); - dateLabel.classList.add('timeline-now-label'); - dateLabel.textContent = `${date.getMonth() + 1}/${date.getDate()}`; - nowMarker.appendChild(dateLabel); - - // Add drag handle - const handle = document.createElement('div'); - handle.classList.add('timeline-now-handle'); - nowMarker.appendChild(handle); - - // Position marker after container is in DOM - nowMarker.dataset.percent = nowPercent; - - // Drag functionality - if (onDateChange) { - let isDragging = false; - let trackBoundsCache = null; - - // Cache bars and track for performance - let barsCache = null; - let lastBestIdx = -1; - - const cacheTrackBounds = () => { - const track = container.querySelector('.timeline-track'); - if (track) { - trackBoundsCache = track.getBoundingClientRect(); - } else { - const months = container.querySelector('.timeline-months'); - trackBoundsCache = months ? months.getBoundingClientRect() : null; - } - // Also cache bars - barsCache = [...container.querySelectorAll('.timeline-bar')]; - }; - - // Update banner bar colors and selection based on the new date - // Returns the current best banner (for passing to onDateChange) - const updateBarColors = (newDate) => { - if (!barsCache) return null; - - let newBestIdx = -1; - - // Update bar classes - for (let i = 0; i < barsCache.length; i += 1) { - const bar = barsCache[i]; - const banner = visibleBanners[i]; - if (!banner) continue; // eslint-disable-line no-continue - - const state = currentPastFuture(banner.start, banner.end, newDate); - - // Update state class (toggle instead of remove all + add) - bar.classList.toggle('timeline-bar-past', state === 'past'); - bar.classList.toggle('timeline-bar-current', state === 'current'); - bar.classList.toggle('timeline-bar-future', state === 'future'); - - if (state === 'current') { - newBestIdx = i; - } - } - - // Update selected class only if changed - if (newBestIdx !== lastBestIdx) { - if (lastBestIdx >= 0 && barsCache[lastBestIdx]) { - barsCache[lastBestIdx].classList.remove('timeline-bar-selected'); - } - if (newBestIdx >= 0 && barsCache[newBestIdx]) { - barsCache[newBestIdx].classList.add('timeline-bar-selected'); - } - lastBestIdx = newBestIdx; - - // Only notify preview change when selection changes - if (onPreviewChange) { - onPreviewChange(newBestIdx >= 0 ? visibleBanners[newBestIdx] : null); - } - } - - return newBestIdx >= 0 ? visibleBanners[newBestIdx] : null; - }; - - const updateMarkerFromX = (clientX) => { - if (!trackBoundsCache || trackBoundsCache.width === 0) return null; - - const relativeX = clientX - trackBoundsCache.left; - const percent = Math.max(0, Math.min(relativeX / trackBoundsCache.width, 1)); - - // Update marker position directly (don't re-render timeline) - const containerRect = container.getBoundingClientRect(); - const leftOffset = trackBoundsCache.left - containerRect.left; - const newLeft = leftOffset + (percent * trackBoundsCache.width); - nowMarker.style.left = `${newLeft}px`; - - // Calculate the new date - const newTime = rangeStart.getTime() + (percent * rangeDuration); - const newDate = new Date(newTime); - - // Update the date label - dateLabel.textContent = `${newDate.getMonth() + 1}/${newDate.getDate()}`; - - // Update bar colors and get current best banner - const currentBest = updateBarColors(newDate); - - return { newDate, bestBanner: currentBest }; - }; - - const onMouseMove = (e) => { - if (!isDragging) return; - e.preventDefault(); - const result = updateMarkerFromX(e.clientX); - if (result) { - onDateChange(result.newDate, result.bestBanner); - } - }; - - const onMouseUp = () => { - isDragging = false; - document.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseup', onMouseUp); - nowMarker.classList.remove('timeline-now-dragging'); - trackBoundsCache = null; - }; - - nowMarker.addEventListener('mousedown', (e) => { - e.preventDefault(); - e.stopPropagation(); - isDragging = true; - cacheTrackBounds(); // Cache bounds at start of drag - nowMarker.classList.add('timeline-now-dragging'); - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - }); - - // Touch support - const onTouchMove = (e) => { - if (!isDragging) return; - e.preventDefault(); - const touch = e.touches[0]; - const result = updateMarkerFromX(touch.clientX); - if (result) { - onDateChange(result.newDate, result.bestBanner); - } - }; - - const onTouchEnd = () => { - isDragging = false; - document.removeEventListener('touchmove', onTouchMove); - document.removeEventListener('touchend', onTouchEnd); - nowMarker.classList.remove('timeline-now-dragging'); - trackBoundsCache = null; - }; - - nowMarker.addEventListener('touchstart', (e) => { - e.preventDefault(); - isDragging = true; - cacheTrackBounds(); - nowMarker.classList.add('timeline-now-dragging'); - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onTouchEnd); - }); - - // Position marker after a frame so DOM is ready - requestAnimationFrame(() => { - const track = container.querySelector('.timeline-track'); - if (track) { - const trackRect = track.getBoundingClientRect(); - const containerRect = container.getBoundingClientRect(); - const leftOffset = trackRect.left - containerRect.left; - const newLeft = leftOffset + (nowPercent / 100) * trackRect.width; - nowMarker.style.left = `${newLeft}px`; - } - }); - } else { - // Non-interactive: use CSS calc - nowMarker.style.left = `${nowPercent}%`; - } - - container.appendChild(nowMarker); - } - - return container; -} - /** * Creates a structured list of parsed banner elements with appropriate CSS classes and content. * @param {Array} banners - Array of banner objects @@ -540,7 +128,7 @@ export default async function decorateAlertBanners(block) { // Full update function (used on initial load and when input changes) const updateViews = (simDate) => { - const simBestBanner = findBestAlertBanner(banners, simDate); + const simBestBanner = findCurrentScheduledItem(banners, simDate); // Update datetime input dtl.value = simDate.toISOString().slice(0, 16); diff --git a/blocks/header/header.css b/blocks/header/header.css index 1031d9b7..8e76e07c 100644 --- a/blocks/header/header.css +++ b/blocks/header/header.css @@ -73,7 +73,7 @@ header section[data-expanded='true'] { } header [aria-hidden='true'] { - display: none; + display: none !important; } @media (width >= 1000px) { @@ -124,12 +124,13 @@ header .nav-sections { overflow-y: auto; } -header .nav-sections [data-source="fragment"] > a, -header .nav-sections [data-source="fragment"] > button { +header .nav-sections .submenu-wrapper[data-source="fragment"] > a, +header .nav-sections .submenu-wrapper[data-source="fragment"] > button { display: none; } -header .nav-sections ul > li a { +header .nav-sections nav > ul > li > a, +header .nav-sections .submenu-wrapper ul > li a { display: block; padding: var(--spacing-100) 0; line-height: 1.625em; @@ -154,11 +155,6 @@ header .nav-sections .submenu-wrapper ul > li { padding: 0 var(--spacing-200); } -header .nav-sections nav > ul > li:not([class]) { - border-top: 1px solid var(--color-gray-700); - font-weight: 500; -} - header .nav-sections .submenu-wrapper ul > li { border-top: 1px dashed var(--color-gray-400); } @@ -246,8 +242,8 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi padding: var(--spacing-80) 0; } - header .nav-sections [data-source="fragment"] > a, - header .nav-sections [data-source="fragment"] > button { + header .nav-sections .submenu-wrapper[data-source="fragment"] > a, + header .nav-sections .submenu-wrapper[data-source="fragment"] > button { display: unset; } @@ -261,12 +257,12 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi flex-direction: row-reverse; gap: var(--spacing-20); } - + header .nav-sections .submenu-wrapper ul > li:not([class]) { padding: 0 var(--spacing-80); } - header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + ul { + header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + * { display: grid; grid-template-columns: repeat(3, minmax(0, 2fr)) 1fr; gap: 0 var(--spacing-100); @@ -286,11 +282,6 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi display: block; } - header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + ul { - grid-template-columns: repeat(4, minmax(0, 1fr)); - } - - header .nav-sections nav > ul > li.submenu-wrapper > ul > li.submenu-wrapper { grid-row: 1 / span 9; padding: 0; @@ -331,6 +322,193 @@ header .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-chi } } +/* legacy submenu ends above, new mega-menu starts below */ +header .nav-sections .nav-mega { + display: flex; + flex-direction: column; +} + +header .nav-sections .nav-category { + display: grid; + grid-template-columns: 1fr 48px; + align-items: center; +} + +header .nav-sections ul > li.nav-category { + border-top: 1px solid var(--color-gray-400); +} + +header .nav-sections .nav-categories > .nav-category:last-child { + border-bottom: 1px solid var(--color-gray-400); +} + +header .nav-sections .nav-category ul > li { + border: none; + padding: 0; +} + +header .nav-sections .nav-category ul > li a { + display: block; + padding: 0.5em 0; +} + +header .nav-sections .nav-category a:hover, +header .nav-sections .nav-category a:focus-visible { + text-decoration: underline; +} + +header .nav-sections .nav-category p { + grid-column: 1; + margin: 0; + padding: var(--spacing-100) 0; + font-weight: bold; + letter-spacing: var(--letter-spacing-s); + text-transform: uppercase; + cursor: pointer; +} + +header .nav-sections .nav-category button.submenu-toggle { + grid-column: 2; + cursor: pointer; +} + +header .nav-sections .nav-category button[aria-expanded="true"] i.symbol-chevron { + transform: rotate(225deg); +} + +header .nav-sections .nav-category > ul { + grid-column: 1 / span 2; +} + +header .nav-sections .nav-category ul > li > strong > a { + font-weight: bold; + text-decoration: underline; + text-underline-offset: var(--spacing-20); +} + +header .nav-sections .nav-category ul > li > strong > a::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: var(--spacing-60); + border: 1.5px solid transparent; + border-top-color: currentcolor; + border-right-color: currentcolor; + border-radius: var(--rounding-s); + transform: rotate(45deg); +} + +header .nav-sections .nav-category ul > li em { + display: inline-block; + margin-left: 1ch; + border-radius: var(--rounding-s); + padding: 0 0.75em; + background-color: var(--color-charcoal); + color: var(--color-white); + font-size: var(--detail-size-s); + font-style: normal; + font-weight: bold; + text-transform: uppercase; +} + +@media (width >= 1000px) { + header .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + div.nav-mega { + display: grid; + grid-template-columns: 4fr 1fr; + gap: var(--spacing-xl); + } + + header .nav-sections .nav-categories { + column-count: 4; + column-gap: var(--spacing-xl); + } + + header .nav-sections ul > li.nav-category { + display: block; + margin-top: var(--spacing-200); + border-top: 0; + padding: 0; + break-inside: avoid; + } + + header .nav-sections .nav-categories .nav-category ul > li { + padding: 0; + } + + header .nav-sections .nav-category:first-child { + margin-top: 0; + } + + header .nav-sections .nav-categories > .nav-category:last-child { + border-bottom: 0; + } + + header .nav-sections .nav-category button.submenu-toggle { + display: none; + } + + header .nav-sections .nav-category p { + padding-top: 0; + border-bottom: 1px solid var(--color-gray-400); + cursor: default; + } + + header .nav-sections .nav-category > ul { + padding: var(--spacing-100) 0 0; + } +} + +/* promo */ +header .nav-sections .nav-promo { + padding: var(--spacing-200); +} + +@media (width < 1000px) { + header .nav-sections .nav-promo { + order: -1; + } +} + +header .nav-sections .nav-promo h2 { + font-family: var(--body-font-family); + font-size: var(--heading-size-l); + font-weight: bold; +} + +header .nav-sections .nav-promo img { + aspect-ratio: 16 / 9; + border-radius: var(--rounding-m); + object-fit: cover; +} + +header .nav-sections .nav-promo .button-wrapper .button { + width: 100%; +} + +header .nav-sections .nav-promo .button-wrapper .button::after { + content: ''; + display: inline-block; + width: 6px; + height: 6px; + margin-left: var(--spacing-60); + border: 1.5px solid transparent; + border-top-color: currentcolor; + border-right-color: currentcolor; + border-radius: var(--rounding-s); + transform: rotate(45deg); +} + +@media (width >= 1000px) { + header .nav-sections .nav-promo { + padding: 0; + } + + header .nav-sections .nav-promo img { + aspect-ratio: unset; + } +} + /* tools */ header .nav-tools { grid-area: tools; @@ -696,9 +874,21 @@ header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-exp background-color: var(--color-dark-charcoal); border-color: var(--color-gray-700); color: var(--color-white); + grid-template-columns: repeat(4, minmax(0, 1fr)); +} + +header.header-commercial .nav-sections nav > ul > li.submenu-wrapper > [aria-expanded="true"] + div.nav-mega { + background-color: var(--color-dark-charcoal); + border-color: var(--color-gray-700); + color: var(--color-white); } header.header-commercial .nav-sections .submenu-wrapper ul > li.submenu-wrapper ul > li a:last-child { border-color: var(--color-gray-700); color: inherit; } + +header.header-commercial .nav-sections .nav-category, +header.header-commercial .nav-sections .nav-category p { + border-color: var(--color-gray-700); +} diff --git a/blocks/header/header.js b/blocks/header/header.js index d624d017..8398620f 100644 --- a/blocks/header/header.js +++ b/blocks/header/header.js @@ -118,6 +118,36 @@ function buildLanguageSelector(tool) { }); } +/** + * Builds a submenu toggle button for a given label and submenu list, wiring up its aria state. + * @param {string} labelText - Text used to generate the submenu id and aria-label + * @param {HTMLElement} submenu - The