Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions core/templatetags/pagination_tags.py
Original file line number Diff line number Diff line change
@@ -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:
Comment thread
jlchilders11 marked this conversation as resolved.
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
18 changes: 18 additions & 0 deletions core/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 1 addition & 1 deletion news/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
1 change: 1 addition & 0 deletions static/css/v3/components.css
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,4 @@
@import "./library-discovery-card.css";
@import "./help-card.css";
@import "./quick-start-card.css";
@import "./pagination.css";
106 changes: 106 additions & 0 deletions static/css/v3/pagination.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
36 changes: 36 additions & 0 deletions templates/v3/examples/_v3_example_section.html
Original file line number Diff line number Diff line change
Expand Up @@ -915,6 +915,42 @@ <h3>{{ section_title }}</h3>
</div>
{% endwith %}

{% with section_title="Pagination" %}
<div class="v3-examples-section__block" id="{{ section_title|slugify }}">
<h3>{{ section_title }}</h3>
<div class="v3-examples-section__example-box">
<form method="get"
action="{% url 'v3-demo-components' %}#pagination"
class="v3-examples-section__stats-lookup"
style="display: flex;
gap: var(--space-default, 8px);
align-items: center;
flex-wrap: wrap;
margin-bottom: var(--space-xlarge)">
<label for="pagination-total-select"
style="color: var(--color-text-secondary); font-size: var(--font-size-small);">Total pages</label>
<select id="pagination-total-select"
name="total"
onchange="this.form.submit()"
style="padding: var(--space-medium);
border: 1px solid var(--color-stroke-mid);
border-radius: var(--border-radius-s, 4px);
background: var(--color-surface-page);
color: var(--color-text-primary)">
{% for choice in demo_pagination.total_choices %}
<option value="{{ choice }}"{% if choice == demo_pagination.total %} selected{% endif %}>
{{ choice }} page{{ choice|pluralize }}
</option>
{% endfor %}
</select>
</form>
<div id="pagination">
{% include "v3/includes/_pagination.html" with pagination_current=demo_pagination.current pagination_total=demo_pagination.total pagination_anchor="pagination" %}
</div>
</div>
</div>
{% endwith %}

{% comment %}
Position Fixed dark mode toggle for quick theme switching without scrolling to the header.
{% endcomment %}
Expand Down
25 changes: 25 additions & 0 deletions templates/v3/includes/_pagination.html
Original file line number Diff line number Diff line change
@@ -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" %}
41 changes: 41 additions & 0 deletions templates/v3/includes/_pagination_nav.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
<nav class="pagination-nav {{ css_class }}" aria-label="Pagination">
<ol class="pagination">

<li class="pagination__item">
{% if pag.has_previous %}
<a href="{{ prev_url }}{% if pagination_anchor %}#{{ pagination_anchor }}{% endif %}" class="pagination__chevron" aria-label="Previous page" rel="prev">
{% include "includes/icon.html" with icon_name="chevron-left" icon_size=20 %}
</a>
{% else %}
<span class="pagination__chevron pagination__chevron--disabled" aria-disabled="true" aria-label="Previous page">
{% include "includes/icon.html" with icon_name="chevron-left" icon_size=20 %}
</span>
{% endif %}
</li>

{% for item in pages %}
<li class="pagination__item">
{% if item == "…" %}
<span class="pagination__ellipsis" aria-hidden="true">…</span>
{% elif item == pag.number %}
<a href="{% querystring page=item %}{% if pagination_anchor %}#{{ pagination_anchor }}{% endif %}" class="pagination__link pagination__link--active" aria-current="page" aria-label="Page {{ item }}">{{ item }}</a>
{% else %}
<a href="{% querystring page=item %}{% if pagination_anchor %}#{{ pagination_anchor }}{% endif %}" class="pagination__link" aria-label="Go to page {{ item }}">{{ item }}</a>
{% endif %}
</li>
{% endfor %}

<li class="pagination__item">
{% if pag.has_next %}
<a href="{{ next_url }}{% if pagination_anchor %}#{{ pagination_anchor }}{% endif %}" class="pagination__chevron" aria-label="Next page" rel="next">
{% include "includes/icon.html" with icon_name="chevron-right" icon_size=20 %}
</a>
{% else %}
<span class="pagination__chevron pagination__chevron--disabled" aria-disabled="true" aria-label="Next page">
{% include "includes/icon.html" with icon_name="chevron-right" icon_size=20 %}
</span>
{% endif %}
</li>

</ol>
</nav>
1 change: 1 addition & 0 deletions templates/v3/posts_list.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ <h1>Posts</h1>
</div>
<div class="post-content__content_column">
{% include 'v3/includes/_post_list_card.html' with heading=header_text items=entry_list only %}
{% include "v3/includes/_pagination.html" %}
</div>
<div class="post-content__user_card_column">
{% if request.user.is_authenticated %}
Expand Down
Loading