diff --git a/core/templatetags/pagination_tags.py b/core/templatetags/pagination_tags.py new file mode 100644 index 000000000..86ee0de8b --- /dev/null +++ b/core/templatetags/pagination_tags.py @@ -0,0 +1,50 @@ +from django import template +from django.core.paginator import Paginator + +register = template.Library() + + +@register.simple_tag(takes_context=True) +def resolve_pagination(context, current=None, total=None): + """Return a Django Page object resolved from context or explicit overrides. + + In a ListView context, returns page_obj directly. + In isolated/demo usage, creates a real Paginator from the given values. + """ + page_obj = context.get("page_obj") + + if isinstance(current, int) and isinstance(total, int): + paginator = Paginator(range(1, total + 1), 1) + return paginator.page(max(1, min(current, paginator.num_pages))) + + if page_obj is not None: + return page_obj + + return Paginator([], 1).page(1) + + +@register.simple_tag +def pagination_range(page, window=2): + """Return an elided page range for the given Page object. + + Always uses an ellipsis for any gap (even a single page), unlike Django's + built-in get_elided_page_range which fills single-page gaps with the page + number. Each element is either an integer or '…'. + """ + num_pages = page.paginator.num_pages + current = page.number + + if num_pages <= 1: + return list(range(1, num_pages + 1)) + + window_start = max(2, current - window) + window_end = min(num_pages - 1, current + window) + + items = [1] + if window_start > 2: + items.append("…") + items.extend(range(window_start, window_end + 1)) + if window_end < num_pages - 1: + items.append("…") + items.append(num_pages) + return items diff --git a/core/views.py b/core/views.py index cf7116a44..195897306 100644 --- a/core/views.py +++ b/core/views.py @@ -2134,4 +2134,22 @@ def get_context_data(self, **kwargs): v3_paths.sort(key=lambda p: p["class_name"]) context["v3_paths"] = v3_paths + _demo_total_choices = [1, 3, 5, 24] + try: + demo_total = int(self.request.GET.get("total", 24)) + if demo_total not in _demo_total_choices: + demo_total = 24 + except (ValueError, TypeError): + demo_total = 24 + try: + demo_page = int(self.request.GET.get("page", 1)) + demo_page = max(1, min(demo_page, demo_total)) + except (ValueError, TypeError): + demo_page = 1 + context["demo_pagination"] = { + "current": demo_page, + "total": demo_total, + "total_choices": _demo_total_choices, + } + return context diff --git a/news/views.py b/news/views.py index 23c51c552..727413810 100644 --- a/news/views.py +++ b/news/views.py @@ -85,7 +85,7 @@ class EntryListView(V3Mixin, ListView): template_name = "news/list.html" v3_template_name = "v3/posts_list.html" ordering = ["-publish_at"] - paginate_by = None # XXX: use pagination in the template! Issue #377 + paginate_by = 10 context_object_name = "entry_list" # Ensure children use the same name header_text = "Latest Posts" filter_value = "all" diff --git a/static/css/v3/components.css b/static/css/v3/components.css index a4abaef64..4a7bb53c1 100644 --- a/static/css/v3/components.css +++ b/static/css/v3/components.css @@ -55,3 +55,4 @@ @import "./library-discovery-card.css"; @import "./help-card.css"; @import "./quick-start-card.css"; +@import "./pagination.css"; diff --git a/static/css/v3/pagination.css b/static/css/v3/pagination.css new file mode 100644 index 000000000..4aea8c101 --- /dev/null +++ b/static/css/v3/pagination.css @@ -0,0 +1,106 @@ +/* + * Pagination component + * Token hierarchy: semantics.css → themes.css (dark overrides) + * Usage: _pagination.html + * + * Two navs are rendered: --wide (±2 window) and --narrow (±1 window). + * CSS toggles which one is visible based on viewport width. + * display:none removes both from the visual layer and the a11y tree. + */ + +.pagination { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: var(--space-default); + padding: var(--space-large) 0; + list-style: none; + margin: 0; +} + +/* ── Shared button styles ── */ + +.pagination__item { + display: flex; +} + +.pagination__link, +.pagination__chevron { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 var(--space-large); + border: 1px solid transparent; + border-radius: var(--border-radius-xl); + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + line-height: var(--line-height-default); + color: var(--color-text-secondary); + text-decoration: none; + transition: background-color 0.15s ease; +} + +/* ── Page number links ── */ + +.pagination__link:hover { + background-color: var(--color-surface-mid); + border-color: var(--color-stroke-weak); + color: var(--color-text-primary); +} + +.pagination__link--active { + background-color: var(--color-surface-weak); + border-color: var(--color-stroke-weak); + color: var(--color-text-primary); + pointer-events: none; +} + +/* ── Ellipsis ── */ + +.pagination__ellipsis { + display: flex; + align-items: center; + justify-content: center; + height: 40px; + padding: 0 var(--space-default); + font-family: var(--font-sans); + font-size: var(--font-size-small); + font-weight: var(--font-weight-regular); + color: var(--color-text-tertiary); + user-select: none; +} + +/* ── Chevron navigation ── */ + +.pagination__chevron { + width: 20px; + height: 20px; + padding: 0; + border: none; + color: var(--color-icon-primary); +} + +.pagination__chevron--disabled { + color: var(--color-text-tertiary); + pointer-events: none; + cursor: default; +} + +/* ── Responsive visibility ── */ + +.pagination-nav--narrow { + display: none; +} + +@media (max-width: 767px) { + .pagination-nav--wide { + display: none; + } + + .pagination-nav--narrow { + display: block; + } +} diff --git a/templates/v3/examples/_v3_example_section.html b/templates/v3/examples/_v3_example_section.html index 578346b47..906f25e61 100644 --- a/templates/v3/examples/_v3_example_section.html +++ b/templates/v3/examples/_v3_example_section.html @@ -915,6 +915,42 @@

{{ section_title }}

{% endwith %} + {% with section_title="Pagination" %} +
+

{{ section_title }}

+
+
+ + +
+ +
+
+ {% endwith %} + {% comment %} Position Fixed dark mode toggle for quick theme switching without scrolling to the header. {% endcomment %} diff --git a/templates/v3/includes/_pagination.html b/templates/v3/includes/_pagination.html new file mode 100644 index 000000000..e49c903b5 --- /dev/null +++ b/templates/v3/includes/_pagination.html @@ -0,0 +1,25 @@ +{% load pagination_tags %} +{% comment %} + Pagination component. + + Standard usage (from ListView — page_obj and paginator are in context): + {% include "v3/includes/_pagination.html" %} + + Isolated / demo usage: + {% include "v3/includes/_pagination.html" with pagination_current=5 pagination_total=24 %} + + Parameters: + pagination_current int – current page number (required for isolated use) + pagination_total int – total number of pages (required for isolated use) + pagination_anchor str – optional HTML id; appended as #fragment to every + page link so the browser scrolls back to the component +{% endcomment %} + +{% resolve_pagination pagination_current pagination_total as pag %} +{% pagination_range pag 2 as pages_wide %} +{% pagination_range pag 1 as pages_narrow %} +{% if pag.has_previous %}{% querystring page=pag.previous_page_number as prev_url %}{% endif %} +{% if pag.has_next %}{% querystring page=pag.next_page_number as next_url %}{% endif %} + +{% include "v3/includes/_pagination_nav.html" with pages=pages_wide css_class="pagination-nav--wide" %} +{% include "v3/includes/_pagination_nav.html" with pages=pages_narrow css_class="pagination-nav--narrow" %} diff --git a/templates/v3/includes/_pagination_nav.html b/templates/v3/includes/_pagination_nav.html new file mode 100644 index 000000000..3fa0d1478 --- /dev/null +++ b/templates/v3/includes/_pagination_nav.html @@ -0,0 +1,41 @@ + diff --git a/templates/v3/posts_list.html b/templates/v3/posts_list.html index 118c894e2..281022a41 100644 --- a/templates/v3/posts_list.html +++ b/templates/v3/posts_list.html @@ -32,6 +32,7 @@

Posts

{% include 'v3/includes/_post_list_card.html' with heading=header_text items=entry_list only %} + {% include "v3/includes/_pagination.html" %}
{% if request.user.is_authenticated %}