diff --git a/core/templatetags/number_filters.py b/core/templatetags/number_filters.py new file mode 100644 index 000000000..e33378b1e --- /dev/null +++ b/core/templatetags/number_filters.py @@ -0,0 +1,30 @@ +""" +Template filters for number formatting. +""" + +from django import template + +register = template.Library() + + +@register.filter +def compact_number(value): + """ + Format integers in compact form: 2300 → 2.3k, 33000 → 33k, 1500000 → 1.5M. + Values under 1000 are shown as-is. Non-numeric values are returned unchanged. + """ + if value is None: + return "" + try: + n = int(value) + except (TypeError, ValueError): + return value + if n < 1000: + return str(n) + if n < 1_000_000: + k = n / 1000 + formatted = f"{k:.1f}".rstrip("0").rstrip(".") + return f"{formatted}k" + m = n / 1_000_000 + formatted = f"{m:.1f}".rstrip("0").rstrip(".") + return f"{formatted}M" diff --git a/static/css/v3/badge.css b/static/css/v3/badge.css new file mode 100644 index 000000000..92c6e1d42 --- /dev/null +++ b/static/css/v3/badge.css @@ -0,0 +1,20 @@ +/** + * V3 Badge – small non-clickable badge (e.g. count on carousel cards). + * Uses Icon/Brand Accent background and Text/On Accent for text. + */ + +.badge-v3 { + display: flex; + width: 16px; + height: 16px; + justify-content: center; + align-items: center; + aspect-ratio: 1 / 1; + box-sizing: border-box; + border-radius: var(--s, 4px); + background: var(--Icon-Brand-Accent, #FFA000); + font-size: 10px; + line-height: 1; + color: var(--color-text-on-accent); + pointer-events: none; +} diff --git a/static/css/v3/carousel-buttons.css b/static/css/v3/carousel-buttons.css index 4ece3c982..79ebc1e19 100644 --- a/static/css/v3/carousel-buttons.css +++ b/static/css/v3/carousel-buttons.css @@ -35,10 +35,15 @@ height: 12px; } -.carousel-buttons .btn-carousel:hover { +.carousel-buttons .btn-carousel:hover:not(:disabled) { color: var(--color-secondary-dark-blue, #0077B8); } -.carousel-buttons .btn-carousel[data-hover] { +.carousel-buttons .btn-carousel[data-hover]:not(:disabled) { color: var(--color-secondary-dark-blue, #0077B8); } + +.carousel-buttons .btn-carousel:disabled { + opacity: 0.5; + cursor: not-allowed; +} diff --git a/static/css/v3/components.css b/static/css/v3/components.css index 3f7b15e71..5597042f0 100644 --- a/static/css/v3/components.css +++ b/static/css/v3/components.css @@ -5,6 +5,7 @@ @import "./buttons.css"; @import "./avatar.css"; @import "./carousel-buttons.css"; +@import "./badge.css"; @import "./v3-examples-section.css"; @import "./header.css"; @import "./footer.css"; diff --git a/static/css/v3/content.css b/static/css/v3/content.css index fbbfc4a61..0b46d216a 100644 --- a/static/css/v3/content.css +++ b/static/css/v3/content.css @@ -44,6 +44,12 @@ a:hover .content-detail-icon:not(.content-detail-icon--contained) { display: block; } +.content-detail-icon__badge-slot { + position: absolute; + top: var(--space-card); + right: var(--space-card); +} + .content-detail-icon__title { margin: 0; font-size: var(--font-size-base); @@ -51,7 +57,8 @@ a:hover .content-detail-icon:not(.content-detail-icon--contained) { line-height: var(--line-height-default); } -.content-detail-icon--has-icon .content-detail-icon__title { +.content-detail-icon--has-icon .content-detail-icon__title, +.content-detail-icon--has-badge .content-detail-icon__title { padding-right: var(--space-icon-offset); } @@ -87,6 +94,12 @@ a:hover .content-detail-icon:not(.content-detail-icon--contained) { .content-detail-icon__cta:hover { text-decoration: underline; } +.content-detail-icon__cta, +.content-detail-icon__cta:focus, +.content-detail-icon__cta:focus-visible { + text-decoration: underline; + text-decoration-skip-ink: none; +} .content-detail-icon--contained { background: transparent; diff --git a/static/css/v3/post-cards.css b/static/css/v3/post-cards.css index 3c8c56edb..d7e7fd4d6 100644 --- a/static/css/v3/post-cards.css +++ b/static/css/v3/post-cards.css @@ -2,6 +2,21 @@ text-decoration: none; } +.post-cards .content-detail-icon__cta, +.post-cards .content-card__cta { + text-decoration: underline; + text-decoration-skip-ink: none; +} + +.post-cards .content-detail-icon__cta:hover, +.post-cards .content-detail-icon__cta:focus, +.post-cards .content-detail-icon__cta:focus-visible, +.post-cards .content-card__cta:hover, +.post-cards .content-card__cta:focus, +.post-cards .content-card__cta:focus-visible { + text-decoration: underline; +} + .post-cards { font-family: var(--font-sans); color: var(--color-text-primary); diff --git a/static/js/carousel.js b/static/js/carousel.js index eefdf5d85..925c64ab2 100644 --- a/static/js/carousel.js +++ b/static/js/carousel.js @@ -3,9 +3,24 @@ const CAROUSEL_STEP_PX_FALLBACK = 320; const SCROLL_RESET_EPSILON = 2; + const SCROLL_END_EPSILON = 1; const DEFAULT_AUTOPLAY_MS = 4000; const CAROUSEL_ITEM_SELECTOR = '[data-carousel-item]'; + function updateArrowState(track, prevBtn, nextBtn) { + if (!track || !prevBtn || !nextBtn) return; + const maxScroll = track.scrollWidth - track.clientWidth; + if (maxScroll <= 0) { + prevBtn.disabled = true; + nextBtn.disabled = true; + return; + } + const atStart = track.scrollLeft <= 0; + const atEnd = track.scrollLeft >= maxScroll - SCROLL_END_EPSILON; + prevBtn.disabled = atStart; + nextBtn.disabled = atEnd; + } + function getStepPx(track) { const first = track.querySelector(CAROUSEL_ITEM_SELECTOR); if (first) { @@ -138,10 +153,26 @@ const prevBtn = controls.querySelector('[data-carousel-prev]'); const nextBtn = controls.querySelector('[data-carousel-next]'); if (prevBtn) { - prevBtn.addEventListener('click', function () { scrollCarousel(track, 'prev', true); }); + prevBtn.addEventListener('click', function () { + if (prevBtn.disabled) return; + scrollCarousel(track, 'prev', true); + }); } if (nextBtn) { - nextBtn.addEventListener('click', function () { scrollCarousel(track, 'next', true); }); + nextBtn.addEventListener('click', function () { + if (nextBtn.disabled) return; + scrollCarousel(track, 'next', true); + }); + } + + const syncArrows = function () { + updateArrowState(track, prevBtn, nextBtn); + }; + requestAnimationFrame(syncArrows); + track.addEventListener('scroll', syncArrows, { passive: true }); + if (typeof ResizeObserver !== 'undefined') { + const ro = new ResizeObserver(syncArrows); + ro.observe(track); } const autoplayDelay = root.getAttribute('data-carousel-autoplay'); diff --git a/templates/v3/includes/_badge_v3.html b/templates/v3/includes/_badge_v3.html new file mode 100644 index 000000000..664f1fbb6 --- /dev/null +++ b/templates/v3/includes/_badge_v3.html @@ -0,0 +1,10 @@ +{% comment %} + V3 Badge – small non-clickable count/label badge. + Variables: + value (required) — number or text to show. Numbers >= 1000 are shown compact (e.g. 2.3k, 33k). + Usage: + {% include "v3/includes/_badge_v3.html" with value=1 %} + {% include "v3/includes/_badge_v3.html" with value=count %} +{% endcomment %} +{% load number_filters %} + diff --git a/templates/v3/includes/_cards_carousel_v3.html b/templates/v3/includes/_cards_carousel_v3.html index 343626862..11aa8d28b 100644 --- a/templates/v3/includes/_cards_carousel_v3.html +++ b/templates/v3/includes/_cards_carousel_v3.html @@ -39,7 +39,7 @@