Skip to content

List View for Image Listing #13172

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 40 commits into from
Jul 12, 2025

Conversation

joelwilliam2005
Copy link
Member

@joelwilliam2005 joelwilliam2005 commented Jun 17, 2025

This PR introduces a new List view for the image listing with a toggle button to switch between grid and list view.

Image Listing Grid View:
Screenshot From 2025-06-18 12-33-11

Image Listing List View:
Screenshot From 2025-06-17 19-25-50

Here's a demo video:
https://github.com/user-attachments/assets/03cfd8a7-433d-49c1-b9d9-b5cf8711fdf4

It is still not polished and requires work.

Design decisions required:

  • New icons (Used temporary icons just for now)
  • File name positioning.

Just an opinion - I think that a separate column for file name would keep the design consistent and it looks better as well. However moving the filename below the title definetly leaves enough room for an extra column ;)

Here's how the listing would look with a separate column for file name.

Screenshot From 2025-06-17 19-25-50

Screenshot From 2025-06-17 19-25-12

@joelwilliam2005
Copy link
Member Author

joelwilliam2005 commented Jun 17, 2025

@benenright Two new icons are required for the button to toggle to list view and grid view. Design decision for the listing is also required here.

@joelwilliam2005 joelwilliam2005 marked this pull request as draft June 18, 2025 06:20
@joelwilliam2005 joelwilliam2005 force-pushed the gsoc/feat/gridToListView branch from 06d6452 to dc81498 Compare June 18, 2025 07:48
@joelwilliam2005 joelwilliam2005 marked this pull request as ready for review June 19, 2025 07:16
@thibaudcolas thibaudcolas added component:Images type:Enhancement component:Universal listings Including page listings, model listings and filtering. labels Jun 19, 2025
@thibaudcolas thibaudcolas added this to the 7.1 milestone Jun 19, 2025
<th>{% trans "Collection" %}</th>
<th>
{% if ordering == "-created_at" %}
<a href="{% querystring ordering='created_at' %}" title="Sort by 'Created' in descending order." class="icon icon-arrow-down-after teal label" >{% trans "Created" %}</a>
Copy link
Member

@allcaps allcaps Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

{% translate "Sort by 'Created' in descending order." %}

Other text based attributes too. ;)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This would probably be fixed if we use Wagtail tables.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 note from Sage this will likely be fixed by switching to the tables / columns framework we use elsewhere in Wagtail. @joelwilliam2005 if that’s not fully taken care of with that refactoring, please take a look at other sorting labels around the admin and try to reuse them, so we avoid creating extra translations.

{% if ordering == "-created_at" %}
<a href="{% querystring ordering='created_at' %}" title="Sort by 'Created' in descending order." class="icon icon-arrow-down-after teal label" >{% trans "Created" %}</a>
{% elif ordering == "created_at" %}
<a href="{% querystring ordering='-created_at' %}" title="Sort by 'Created' in ascending order." class="icon icon-arrow-up-after teal label" >{% trans "Created" %}</a>
Copy link
Member

@allcaps allcaps Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

New preferred way is to write this out {% translate ...
https://docs.djangoproject.com/en/5.2/topics/i18n/translation/#translate-template-tag

{% block list_view %}
{% panel id="listings" %}

<table class="listing">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From Sage: this should be refactored to use Wagtail’s table framework (see document listing), where table columns are defined as Python objects.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. You'll need to define a columns property on the view class. Here's an example for the documents listing view:

@cached_property
def columns(self):
columns = [
BulkActionsColumn("bulk_actions"),
TitleColumn(
"title",
label=_("Title"),
sort_key="title",
get_url=self.get_edit_url,
get_title_id=lambda doc: f"document_{quote(doc.pk)}_title",
),
DownloadColumn("filename", label=_("File")),
DateColumn(
"created_at",
label=_("Created"),
sort_key="created_at",
width="16%",
),
]
if self.filters and "collection_id" in self.filters.filters:
columns.insert(
3,
Column("collection", label=_("Collection"), accessor="collection.name"),
)
return columns

You might need to return an empty list if view_mode is grid. Maybe extract it into a cached property on the view class so you can use it here e.g.

if self.view_mode == "grid":
    return []

You can create custom Column subclasses if you need to make customisations. For example, the UserColumn on the users listing view was created so that it can display the user's avatar along with their name:

class UserColumn(TitleColumn):
cell_template_name = "wagtailusers/users/user_cell.html"

{% block title %}
<div class="w-inline-flex w-items-center">
{% avatar user=instance size="small" classname="w-shrink-0" %}
{{ block.super }}
</div>
{% endblock %}

I think you'll need to create the following custom columns:

  • A custom BulkActionsCheckboxColumn subclass for images. See the example for documents:
    class BulkActionsColumn(BulkActionsCheckboxColumn):
    def __init__(self, *args, **kwargs):
    super().__init__(*args, obj_type="document", **kwargs)
    def get_header_context_data(self, parent_context):
    context = super().get_header_context_data(parent_context)
    parent = parent_context.get("current_collection")
    if parent:
    context["parent"] = parent.id
    return context
  • A new ImageColumn to render the image as a separate column
  • A custom TitleColumn subclass in order to display the image file name along with the title.

The main code for the tables framework can be found at https://github.com/wagtail/wagtail/blob/main/wagtail/admin/ui/tables/__init__.py

Once you've set up the columns, you should be able to do something like

    {% if view_mode == "list" %}
        {{ block.super }}
    {% else %}
        {# Keep the current code #}
    {% endif %}

<th>{% trans "Preview" %}</th>
<th>
{% if ordering == "-title" %}
<a href="{% querystring ordering='title' %}" title="Sort by 'Title' in descending order." class="icon icon-arrow-down-after teal label" >{% trans "Title" %}</a>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Likely not relevant (see above) – if we keep this, we’d need to use Wagtail’s own implementation of querystring rather than Django’s, as we still have to support Django 4.2

context.update(
{
"next": self.get_next_url(),
"current_collection": self.current_collection,
"current_ordering": self.ordering,
"ORDERING_OPTIONS": self.ORDERING_OPTIONS,
"view_mode": view_mode,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need tests that confirm:

  • the view mode is passed correctly
  • the listings’ HTML are adapted based on the view mode

From Sage: need tests that would fail if they ran against the previous implementation of those listings (so demonstrate the changes add this new behavior).


Assert contents of title column with title + filename (below title), matching the test’s created image

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The test client is a Python class that acts as a dummy web browser, allowing you to test your views and interact with your Django-powered application programmatically. It comes with html asserts, on of them:
https://docs.djangoproject.com/en/5.2/topics/testing/tools/#django.test.SimpleTestCase.assertInHTML

The WagtailTestCase also has a BeautifulSoup helper:
utils.wagtail_tests.WagtailTestUtils.get_soup.
BS is an awesome package to parse HTML and validate its contents: https://www.crummy.com/software/BeautifulSoup/

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of testing the markup:

def test_simple(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
soup = self.get_soup(response.content)
h1 = soup.select_one("h1")
self.assertEqual(h1.text.strip(), f"Usage: {self.object}")
tds = soup.select("#listing-results tbody tr td")
self.assertEqual(len(tds), 3)
self.assertEqual(tds[0].text.strip(), str(self.tbx))
self.assertEqual(tds[1].text.strip(), "Various on delete model")
self.assertEqual(tds[2].text.strip(), "Cascading toy")
tbx_edit_url = AdminURLFinder(self.user).get_edit_url(self.tbx)
# Link to referrer's edit view
link = tds[0].select_one("a")
self.assertIsNotNone(link)
self.assertEqual(link.attrs.get("href"), tbx_edit_url)
content_path_link = tds[-1].select_one("a")
self.assertEqual(
content_path_link.attrs.get("href"),
tbx_edit_url + "#:w:contentpath=cascading_toy",
)
# Link to referrer's edit view with parameters for the specific field
link = tds[2].select_one("a")
self.assertIsNotNone(link)
self.assertIn(tbx_edit_url, link.attrs.get("href"))
# Usage view is not searchable
input = soup.select_one("input#id_q")
self.assertIsNone(input)
self.assertFalse(response.context.get("search_form"))

The tests should go in https://github.com/wagtail/wagtail/blob/main/wagtail/images/tests/test_admin_views.py

<a href="?view=grid">
<button
type="button"
class="w-filter-button"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to change that class name so it’s clearer it’s reused in multiple buttons.

type="button"
class="w-filter-button"
aria-label="{% trans 'Toggle view' %}"
style="align-items: center; justify-content: center; display: flex"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This will need to be done with vanilla CSS or Tailwind utility classes, we can’t use inline styles.

<button
type="button"
class="w-filter-button"
aria-label="{% trans 'Toggle view' %}"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be switched to a tooltip like we have for the existing filter button. And changed the label to Toggle layout.

{% include "wagtailadmin/bulk_actions/listing_checkbox_cell.html" with obj_type="image" instance=image aria_describedby=title_id only %}
<td>
<a href="{% url view.edit_url_name image.id %}{% if next %}?next={{ next|urlencode }}{% endif %}">
<div class="image">{% image image max-105x105 class="show-transparency" alt="" %}</div>
Copy link
Member

@thibaudcolas thibaudcolas Jun 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove alt="" to get the default behavior of populating alt from the image’s description or title.

Suggested change
<div class="image">{% image image max-105x105 class="show-transparency" alt="" %}</div>
<div class="image">{% image image max-105x105 class="show-transparency" %}</div>

Copy link
Member

@laymonage laymonage left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice progress!

{% block list_view %}
{% panel id="listings" %}

<table class="listing">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep. You'll need to define a columns property on the view class. Here's an example for the documents listing view:

@cached_property
def columns(self):
columns = [
BulkActionsColumn("bulk_actions"),
TitleColumn(
"title",
label=_("Title"),
sort_key="title",
get_url=self.get_edit_url,
get_title_id=lambda doc: f"document_{quote(doc.pk)}_title",
),
DownloadColumn("filename", label=_("File")),
DateColumn(
"created_at",
label=_("Created"),
sort_key="created_at",
width="16%",
),
]
if self.filters and "collection_id" in self.filters.filters:
columns.insert(
3,
Column("collection", label=_("Collection"), accessor="collection.name"),
)
return columns

You might need to return an empty list if view_mode is grid. Maybe extract it into a cached property on the view class so you can use it here e.g.

if self.view_mode == "grid":
    return []

You can create custom Column subclasses if you need to make customisations. For example, the UserColumn on the users listing view was created so that it can display the user's avatar along with their name:

class UserColumn(TitleColumn):
cell_template_name = "wagtailusers/users/user_cell.html"

{% block title %}
<div class="w-inline-flex w-items-center">
{% avatar user=instance size="small" classname="w-shrink-0" %}
{{ block.super }}
</div>
{% endblock %}

I think you'll need to create the following custom columns:

  • A custom BulkActionsCheckboxColumn subclass for images. See the example for documents:
    class BulkActionsColumn(BulkActionsCheckboxColumn):
    def __init__(self, *args, **kwargs):
    super().__init__(*args, obj_type="document", **kwargs)
    def get_header_context_data(self, parent_context):
    context = super().get_header_context_data(parent_context)
    parent = parent_context.get("current_collection")
    if parent:
    context["parent"] = parent.id
    return context
  • A new ImageColumn to render the image as a separate column
  • A custom TitleColumn subclass in order to display the image file name along with the title.

The main code for the tables framework can be found at https://github.com/wagtail/wagtail/blob/main/wagtail/admin/ui/tables/__init__.py

Once you've set up the columns, you should be able to do something like

    {% if view_mode == "list" %}
        {{ block.super }}
    {% else %}
        {# Keep the current code #}
    {% endif %}

@@ -111,12 +111,15 @@ def get_next_url(self):
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)

view_mode = self.request.GET.get("view", "grid")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably worth extracting into a cached property on the view, e.g.

@cached_property
def view_mode(self):
    return self.request.GET.get("view", "grid")

So you can use it in other methods/properties as self.view_mode, e.g. when you're defining the columns.

And if we do want to preserve it in the session, we could do something like

@cached_property
def view_mode(self):
    preferred_view = self.request.GET.get("view")
    if preferred_view:
        request.session["wagtailimages_view_mode"] = preferred_view
    else:
        preferred_view = request.session.get("wagtailimages_view_mode", "grid")
    return preferred_view

@@ -110,6 +113,8 @@ <h1 class="w-sr-only">
required as the content is dynamically loaded.
{% endcomment %}
<input hidden disabled type="submit" />
<input type="hidden" name="view" value="{{ view_mode }}">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned by Thibaud, try to see if we can put this in the extra_form_fields instead.

And since this is specific to images, this should go in the image_listing_header.html rather than the generic slim_header.html

context.update(
{
"next": self.get_next_url(),
"current_collection": self.current_collection,
"current_ordering": self.ordering,
"ORDERING_OPTIONS": self.ORDERING_OPTIONS,
"view_mode": view_mode,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's an example of testing the markup:

def test_simple(self):
response = self.client.get(self.url)
self.assertEqual(response.status_code, 200)
soup = self.get_soup(response.content)
h1 = soup.select_one("h1")
self.assertEqual(h1.text.strip(), f"Usage: {self.object}")
tds = soup.select("#listing-results tbody tr td")
self.assertEqual(len(tds), 3)
self.assertEqual(tds[0].text.strip(), str(self.tbx))
self.assertEqual(tds[1].text.strip(), "Various on delete model")
self.assertEqual(tds[2].text.strip(), "Cascading toy")
tbx_edit_url = AdminURLFinder(self.user).get_edit_url(self.tbx)
# Link to referrer's edit view
link = tds[0].select_one("a")
self.assertIsNotNone(link)
self.assertEqual(link.attrs.get("href"), tbx_edit_url)
content_path_link = tds[-1].select_one("a")
self.assertEqual(
content_path_link.attrs.get("href"),
tbx_edit_url + "#:w:contentpath=cascading_toy",
)
# Link to referrer's edit view with parameters for the specific field
link = tds[2].select_one("a")
self.assertIsNotNone(link)
self.assertIn(tbx_edit_url, link.attrs.get("href"))
# Usage view is not searchable
input = soup.select_one("input#id_q")
self.assertIsNone(input)
self.assertFalse(response.context.get("search_form"))

The tests should go in https://github.com/wagtail/wagtail/blob/main/wagtail/images/tests/test_admin_views.py

Copy link
Member

@thibaudcolas thibaudcolas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Couple extra comments – looking solid @joelwilliam2005!

@@ -1,6 +1,10 @@
{% extends "wagtailadmin/shared/headers/slim_header.html" %}
{% load wagtailadmin_tags %}

{% block view_toggle %}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can use the pre-existing block which will place the button where expected.

Suggested change
{% block view_toggle %}
{% block extra_form_fields %}

<th>{% trans "Collection" %}</th>
<th>
{% if ordering == "-created_at" %}
<a href="{% querystring ordering='created_at' %}" title="Sort by 'Created' in descending order." class="icon icon-arrow-down-after teal label" >{% trans "Created" %}</a>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍 note from Sage this will likely be fixed by switching to the tables / columns framework we use elsewhere in Wagtail. @joelwilliam2005 if that’s not fully taken care of with that refactoring, please take a look at other sorting labels around the admin and try to reuse them, so we avoid creating extra translations.

{% load wagtailadmin_tags i18n %}

<div id="view-toggle-button-wrapper">
{% if view_mode == "list" %}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggest we change naming from view / view mode to "layout" (layout mode?)

@@ -365,3 +365,28 @@
color: theme('colors.text-link-hover');
}
}

.w-view-toggle-button {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should be reusing the same styles as w-filter-button. Could you refactor both to use a generic name? Looks like those new w-view-toggle-button styles could be applied to both.

{% block title %}
<a class="image-choice" href="{% url 'wagtailimages:edit' instance.id %}{% if next %}?next={{ next|urlencode }}{% endif %}">
<figure>
{% image instance max-105x105 class="show-transparency"%}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{% image instance max-105x105 class="show-transparency"%}
{% image instance max-165x165 class="show-transparency" %}

Worthwhile for us to reuse the same rendition as everywhere else, for performance. Resize with CSS afterwards if needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note: need to double check what happens with tall vs. wide images. @thibaudcolas review other software with Ben, and alignment of text

Comment on lines 6 to 7
<a class="image-choice" href="{% url 'wagtailimages:edit' instance.id %}{% if next %}?next={{ next|urlencode }}{% endif %}">
<figure>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remove figure tag? And try to get the focus outline around the image to more closely follow the image’s size

type="button"
class="w-view-toggle-button"
data-controller="w-tooltip"
data-w-tooltip-content-value="Toggle layout"
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Needs to be translateable

</ul>
</div>
{% if view_mode == "list" %}
{{block.super}}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
{{block.super}}
{{ block.super }}

Copilot

This comment was marked as outdated.


<div id="view-toggle-button-wrapper">
{% if view_mode == "list" %}
<a href="?view=grid">
Copy link
Member

@laymonage laymonage Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree with Copilot here, I think it'd be great if we could retain the search and ordering. Wagtail has its own implementation of the {% querystring %} template tag to do this, which you can try to use e.g.

Suggested change
<a href="?view=grid">
<a href="{{ index_url }}{% querystring layout="grid" %}">

That being said, this may not be necessary if we use the utilise the search/filter form to do this for us. See my other comment for details.

{% rawformattedfield label_text=_("Sort by") id_for_label="order_images_by" sr_only_label=True %}
<select id="order_images_by" name="ordering">
{% for ordering, ordering_text in ORDERING_OPTIONS.items %}
<option value="{{ ordering }}" {% if current_ordering == ordering %}selected="selected"{% endif %}>{{ ordering_text }}</option>
{% endfor %}
</select>
{% endrawformattedfield %}
<input type="hidden" name="view" value="{{ view_mode }}">
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Per my other comment, we could turn this into a proper checkbox input in the view_toggle_button.html

Suggested change
<input type="hidden" name="view" value="{{ view_mode }}">

Comment on lines 5 to 25
<a href="?view=grid">
<button
type="button"
class="w-view-toggle-button"
data-controller="w-tooltip"
data-w-tooltip-content-value="Toggle layout"
>
{% icon name="grip" %}
</button>
</a>

{% else %}
<a href="?view=list">
<button
type="button"
class="w-view-toggle-button"
data-controller="w-tooltip"
data-w-tooltip-content-value="Toggle layout"
>
{% icon name="list-ul" %}
</button>
Copy link
Member

@laymonage laymonage Jun 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we should turn this into a proper toggle input, i.e. a checkbox. This would allow us to piggyback on the Universal Listing's implementation of AJAX-based loading of the results instead of a full page reload. For example, I was able to get this working by using a hidden checkbox input and turning the button into a <label> that wraps it. Try replacing the whole file with the following content:

{% load wagtailadmin_tags i18n %}

<label class="w-view-toggle-button w-cursor-pointer" data-controller="w-tooltip" data-w-tooltip-content-value="{% trans 'Toggle layout' %}">
    {% if view_mode == "list" %}
        {% icon name="grip" %}
    {% else %}
        {% icon name="list-ul" %}
    {% endif %}
    <input hidden type="checkbox" name="view" value="list" {% if view_mode == "list" %}checked{% endif %}>
</label>

(Note that the w-cursor-pointer should probably be incorporated inside the styles for w-view-toggle-button instead, so we don't have to use this Tailwind class separately.)

And then, you'll need to remove the existing hidden input in index.html

-         <input type="hidden" name="view" value="{{ view_mode }}">

One hurdle is that we need to update the icon as well. This can be done with CSS, but it does mean we need something specific to this button being a toggle (rather than a generic "header button" CSS suggested by Thibaud). Alternatively, we can use JS with the TeleportController, similar to how we do it for the filters.

CSS approach

Changing the icon with a CSS approach can be done like the following patch. You can save the following diff to a file e.g. ajax-toggle-css.diff and do git apply ajax-toggle-css.diff

diff --git a/client/scss/components/_button.scss b/client/scss/components/_button.scss
index 875a1313ef..391fc67163 100644
--- a/client/scss/components/_button.scss
+++ b/client/scss/components/_button.scss
@@ -389,4 +389,20 @@
   &:hover {
     border-color: theme('colors.border-field-hover');
   }
+
+  .w-view-toggle-button__icon--checked {
+    display: none;
+  }
+  .w-view-toggle-button__icon--unchecked {
+    display: block;
+  }
+
+  &:has(input:checked) {
+    .w-view-toggle-button__icon--checked {
+      display: block;
+    }
+    .w-view-toggle-button__icon--unchecked {
+      display: none;
+    }
+  }
 }
diff --git a/wagtail/images/templates/wagtailimages/images/index.html b/wagtail/images/templates/wagtailimages/images/index.html
index b5d43ea5b0..c299f829a1 100644
--- a/wagtail/images/templates/wagtailimages/images/index.html
+++ b/wagtail/images/templates/wagtailimages/images/index.html
@@ -16,7 +16,6 @@
                 {% endfor %}
             </select>
         {% endrawformattedfield %}
-        <input type="hidden" name="view" value="{{ view_mode }}">
     {% endfragment %}
     {% include "wagtailimages/images/image_listing_header.html" with breadcrumbs_items=breadcrumbs_items side_panels=side_panels history_url=history_url title=header_title search_url=index_results_url search_form=search_form filters=filters buttons=header_buttons icon_name=header_icon extra_form_fields=extra_form_fields only %}
 {% endblock %}
diff --git a/wagtail/images/templates/wagtailimages/images/view_toggle_button.html b/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
index f6274a8c60..f71940f87c 100644
--- a/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
+++ b/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
@@ -1,28 +1,7 @@
 {% load wagtailadmin_tags i18n %}

-<div id="view-toggle-button-wrapper">
-    {% if view_mode == "list" %}
-        <a href="?view=grid">
-            <button
-                type="button"
-                class="w-view-toggle-button"
-                data-controller="w-tooltip"
-                data-w-tooltip-content-value="Toggle layout"
-            >
-                {% icon name="grip" %}
-            </button>
-        </a>
-
-    {% else %}
-        <a href="?view=list">
-            <button
-                type="button"
-                class="w-view-toggle-button"
-                data-controller="w-tooltip"
-                data-w-tooltip-content-value="Toggle layout"
-            >
-                {% icon name="list-ul" %}
-            </button>
-        </a>
-    {% endif %}
-</div>
+<label class="w-view-toggle-button w-cursor-pointer" data-controller="w-tooltip" data-w-tooltip-content-value="{% trans 'Toggle layout' %}">
+    {% icon name="grip" classname="w-view-toggle-button__icon--checked" %}
+    {% icon name="list-ul" classname="w-view-toggle-button__icon--unchecked" %}
+    <input hidden type="checkbox" name="view" value="list" {% if view_mode == "list" %}checked{% endif %}>
+</label>

JS approach

This is similar to how we handle any updates to the header elements (e.g. buttons, filter form) when the results are reloaded.

{% block before_results %}
{% if view.active_filters %}
{% include "wagtailadmin/shared/active_filters.html" with active_filters=view.active_filters %}
{% endif %}
{% if render_filters_fragment %}
<template data-controller="w-teleport" data-w-teleport-target-value="#filters-drilldown" data-w-teleport-reset-value="true">
{% include "wagtailadmin/shared/headers/_filters.html" with filters=filters %}
</template>
{% endif %}
{% if render_buttons_fragment %}
<template data-controller="w-teleport" data-w-teleport-target-value="#w-slim-header-buttons" data-w-teleport-reset-value="true">
{% for button in header_buttons %}
{% component button %}
{% endfor %}
</template>
{% endif %}

Again, you can apply the following patch on top of your current branch to test it:

diff --git a/wagtail/images/templates/wagtailimages/images/index.html b/wagtail/images/templates/wagtailimages/images/index.html
index b5d43ea5b0..95672a3875 100644
--- a/wagtail/images/templates/wagtailimages/images/index.html
+++ b/wagtail/images/templates/wagtailimages/images/index.html
@@ -8,7 +8,9 @@

 {% block slim_header %}
     {% fragment as extra_form_fields %}
-        {% include "wagtailimages/images/view_toggle_button.html" %}
+        <div id="view-toggle-button">
+            {% include "wagtailimages/images/view_toggle_button.html" %}
+        </div>
         {% rawformattedfield label_text=_("Sort by") id_for_label="order_images_by" sr_only_label=True %}
             <select id="order_images_by" name="ordering">
                 {% for ordering, ordering_text in ORDERING_OPTIONS.items %}
@@ -16,7 +18,6 @@
                 {% endfor %}
             </select>
         {% endrawformattedfield %}
-        <input type="hidden" name="view" value="{{ view_mode }}">
     {% endfragment %}
     {% include "wagtailimages/images/image_listing_header.html" with breadcrumbs_items=breadcrumbs_items side_panels=side_panels history_url=history_url title=header_title search_url=index_results_url search_form=search_form filters=filters buttons=header_buttons icon_name=header_icon extra_form_fields=extra_form_fields only %}
 {% endblock %}
diff --git a/wagtail/images/templates/wagtailimages/images/index_results.html b/wagtail/images/templates/wagtailimages/images/index_results.html
index 4a1c3c1cf0..5776e017d0 100644
--- a/wagtail/images/templates/wagtailimages/images/index_results.html
+++ b/wagtail/images/templates/wagtailimages/images/index_results.html
@@ -2,6 +2,14 @@
 {% load wagtailimages_tags wagtailadmin_tags %}
 {% load i18n l10n %}

+{% block before_results %}
+    {{ block.super }}
+
+    <template data-controller="w-teleport" data-w-teleport-target-value="#view-toggle-button" data-w-teleport-reset-value="true">
+        {% include "wagtailimages/images/view_toggle_button.html" %}
+    </template>
+{% endblock %}
+
 {% block results %}
     {% if view_mode == "list" %}
         {{block.super}}
diff --git a/wagtail/images/templates/wagtailimages/images/view_toggle_button.html b/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
index f6274a8c60..98e88874ff 100644
--- a/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
+++ b/wagtail/images/templates/wagtailimages/images/view_toggle_button.html
@@ -1,28 +1,10 @@
 {% load wagtailadmin_tags i18n %}

-<div id="view-toggle-button-wrapper">
+<label class="w-view-toggle-button w-cursor-pointer" data-controller="w-tooltip" data-w-tooltip-content-value="{% trans 'Toggle layout' %}">
     {% if view_mode == "list" %}
-        <a href="?view=grid">
-            <button
-                type="button"
-                class="w-view-toggle-button"
-                data-controller="w-tooltip"
-                data-w-tooltip-content-value="Toggle layout"
-            >
-                {% icon name="grip" %}
-            </button>
-        </a>
-
+        {% icon name="grip" %}
     {% else %}
-        <a href="?view=list">
-            <button
-                type="button"
-                class="w-view-toggle-button"
-                data-controller="w-tooltip"
-                data-w-tooltip-content-value="Toggle layout"
-            >
-                {% icon name="list-ul" %}
-            </button>
-        </a>
+        {% icon name="list-ul" %}
     {% endif %}
-</div>
+    <input hidden type="checkbox" name="view" value="list" {% if view_mode == "list" %}checked{% endif %}>
+</label>

Feel free to play around with both approaches and let me know your thoughts. Also would love to hear your opinions @thibaudcolas and @allcaps.

@joelwilliam2005 joelwilliam2005 requested a review from Copilot June 27, 2025 17:12
Copilot

This comment was marked as outdated.

thibaudcolas added a commit to joelwilliam2005/wagtail that referenced this pull request Jul 12, 2025
@thibaudcolas thibaudcolas force-pushed the gsoc/feat/gridToListView branch from b797990 to fa2ab3c Compare July 12, 2025 06:46
Copy link
Member

@thibaudcolas thibaudcolas left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking good! Tweaked the toggle button to fix the small accessibility issues we had discussed.

The icons also need very specific integration steps when creating new ones (see UI guideline docs).

@thibaudcolas thibaudcolas force-pushed the gsoc/feat/gridToListView branch from fa2ab3c to a5cd658 Compare July 12, 2025 07:13
@thibaudcolas thibaudcolas merged commit f669e76 into wagtail:main Jul 12, 2025
18 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component:Images component:Universal listings Including page listings, model listings and filtering. type:Enhancement
Projects
Status: Done
Development

Successfully merging this pull request may close these issues.

5 participants