Repository: sissbruecker/linkding
Pull request #974 (2025-02-01 23:17:05)

Improve accessibility of modal dialogs


Patch:
diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js
index 5646969..cf6c408 100644
--- a/bookmarks/frontend/behaviors/details-modal.js
+++ b/bookmarks/frontend/behaviors/details-modal.js
@@ -1,61 +1,28 @@
-import { Behavior, registerBehavior } from "./index";
-
-class DetailsModalBehavior extends Behavior {
-  constructor(element) {
-    super(element);
-
-    this.onClose = this.onClose.bind(this);
-    this.onKeyDown = this.onKeyDown.bind(this);
-
-    this.overlayLink = element.querySelector("a:has(.modal-overlay)");
-    this.buttonLink = element.querySelector("a:has(button.close)");
-
-    this.overlayLink.addEventListener("click", this.onClose);
-    this.buttonLink.addEventListener("click", this.onClose);
-    document.addEventListener("keydown", this.onKeyDown);
-  }
-
-  destroy() {
-    this.overlayLink.removeEventListener("click", this.onClose);
-    this.buttonLink.removeEventListener("click", this.onClose);
-    document.removeEventListener("keydown", this.onKeyDown);
-  }
-
-  onKeyDown(event) {
-    // Skip if event occurred within an input element
-    const targetNodeName = event.target.nodeName;
-    const isInputTarget =
-      targetNodeName === "INPUT" ||
-      targetNodeName === "SELECT" ||
-      targetNodeName === "TEXTAREA";
-
-    if (isInputTarget) {
-      return;
-    }
-
-    if (event.key === "Escape") {
-      this.onClose(event);
-    }
-  }
-
-  onClose(event) {
-    event.preventDefault();
-    this.element.classList.add("closing");
-    this.element.addEventListener(
-      "animationend",
-      (event) => {
-        if (event.animationName === "fade-out") {
-          this.element.remove();
-
-          const closeUrl = this.overlayLink.href;
-          Turbo.visit(closeUrl, {
-            action: "replace",
-            frame: "details-modal",
-          });
-        }
-      },
-      { once: true },
-    );
+import { registerBehavior } from "./index";
+import { isKeyboardActive } from "./focus-utils";
+import { ModalBehavior } from "./modal";
+
+class DetailsModalBehavior extends ModalBehavior {
+  doClose() {
+    super.doClose();
+
+    // Navigate to close URL
+    const closeUrl = this.element.dataset.closeUrl;
+    Turbo.visit(closeUrl, {
+      action: "replace",
+      frame: "details-modal",
+    });
+
+    // Try restore focus to view details to view details link of respective bookmark
+    const bookmarkId = this.element.dataset.bookmarkId;
+    const restoreFocusElement =
+      document.querySelector(
+        `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`,
+      ) ||
+      document.querySelector("ul.bookmark-list") ||
+      document.body;
+
+    restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
   }
 }
 
diff --git a/bookmarks/frontend/behaviors/focus-utils.js b/bookmarks/frontend/behaviors/focus-utils.js
new file mode 100644
index 0000000..ab6053a
--- /dev/null
+++ b/bookmarks/frontend/behaviors/focus-utils.js
@@ -0,0 +1,59 @@
+let keyboardActive = false;
+
+window.addEventListener(
+  "keydown",
+  () => {
+    keyboardActive = true;
+  },
+  { capture: true },
+);
+
+window.addEventListener(
+  "mousedown",
+  () => {
+    keyboardActive = false;
+  },
+  { capture: true },
+);
+
+export function isKeyboardActive() {
+  return keyboardActive;
+}
+
+export class FocusTrapController {
+  constructor(element) {
+    this.element = element;
+    this.focusableElements = this.element.querySelectorAll(
+      'a[href]:not([disabled]), button:not([disabled]), textarea:not([disabled]), input[type="text"]:not([disabled]), input[type="radio"]:not([disabled]), input[type="checkbox"]:not([disabled]), select:not([disabled])',
+    );
+    this.firstFocusableElement = this.focusableElements[0];
+    this.lastFocusableElement =
+      this.focusableElements[this.focusableElements.length - 1];
+
+    this.onKeyDown = this.onKeyDown.bind(this);
+
+    this.firstFocusableElement.focus({ focusVisible: keyboardActive });
+    this.element.addEventListener("keydown", this.onKeyDown);
+  }
+
+  destroy() {
+    this.element.removeEventListener("keydown", this.onKeyDown);
+  }
+
+  onKeyDown(event) {
+    if (event.key !== "Tab") {
+      return;
+    }
+    if (event.shiftKey) {
+      if (document.activeElement === this.firstFocusableElement) {
+        event.preventDefault();
+        this.lastFocusableElement.focus();
+      }
+    } else {
+      if (document.activeElement === this.lastFocusableElement) {
+        event.preventDefault();
+        this.firstFocusableElement.focus();
+      }
+    }
+  }
+}
diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js
new file mode 100644
index 0000000..78d788c
--- /dev/null
+++ b/bookmarks/frontend/behaviors/modal.js
@@ -0,0 +1,83 @@
+import { Behavior } from "./index";
+import { FocusTrapController } from "./focus-utils";
+
+export class ModalBehavior extends Behavior {
+  constructor(element) {
+    super(element);
+
+    this.onClose = this.onClose.bind(this);
+    this.onKeyDown = this.onKeyDown.bind(this);
+
+    this.overlay = element.querySelector(".modal-overlay");
+    this.closeButton = element.querySelector(".modal-header .close");
+
+    this.overlay.addEventListener("click", this.onClose);
+    this.closeButton.addEventListener("click", this.onClose);
+    document.addEventListener("keydown", this.onKeyDown);
+
+    this.setupInert();
+    this.focusTrap = new FocusTrapController(
+      element.querySelector(".modal-container"),
+    );
+  }
+
+  destroy() {
+    this.overlay.removeEventListener("click", this.onClose);
+    this.closeButton.removeEventListener("click", this.onClose);
+    document.removeEventListener("keydown", this.onKeyDown);
+
+    this.clearInert();
+    this.focusTrap.destroy();
+  }
+
+  setupInert() {
+    // Inert all other elements on the page
+    document
+      .querySelectorAll("body > *:not(.modals)")
+      .forEach((el) => el.setAttribute("inert", ""));
+  }
+
+  clearInert() {
+    // Clear inert attribute from all elements to allow focus outside the modal again
+    document
+      .querySelectorAll("body > *")
+      .forEach((el) => el.removeAttribute("inert"));
+  }
+
+  onKeyDown(event) {
+    // Skip if event occurred within an input element
+    const targetNodeName = event.target.nodeName;
+    const isInputTarget =
+      targetNodeName === "INPUT" ||
+      targetNodeName === "SELECT" ||
+      targetNodeName === "TEXTAREA";
+
+    if (isInputTarget) {
+      return;
+    }
+
+    if (event.key === "Escape") {
+      this.onClose(event);
+    }
+  }
+
+  onClose(event) {
+    event.preventDefault();
+    this.element.classList.add("closing");
+    this.element.addEventListener(
+      "animationend",
+      (event) => {
+        if (event.animationName === "fade-out") {
+          this.doClose();
+        }
+      },
+      { once: true },
+    );
+  }
+
+  doClose() {
+    this.element.remove();
+    this.clearInert();
+    this.element.dispatchEvent(new CustomEvent("modal:close"));
+  }
+}
diff --git a/bookmarks/frontend/behaviors/tag-modal.js b/bookmarks/frontend/behaviors/tag-modal.js
index 9963bff..f515d5d 100644
--- a/bookmarks/frontend/behaviors/tag-modal.js
+++ b/bookmarks/frontend/behaviors/tag-modal.js
@@ -1,29 +1,31 @@
 import { Behavior, registerBehavior } from "./index";
+import { ModalBehavior } from "./modal";
+import { isKeyboardActive } from "./focus-utils";
 
-class TagModalBehavior extends Behavior {
+class TagModalTriggerBehavior extends Behavior {
   constructor(element) {
     super(element);
 
     this.onClick = this.onClick.bind(this);
-    this.onClose = this.onClose.bind(this);
 
     element.addEventListener("click", this.onClick);
   }
 
   destroy() {
-    this.onClose();
     this.element.removeEventListener("click", this.onClick);
   }
 
   onClick() {
+    // Creates a new modal and teleports the tag cloud into it
     const modal = document.createElement("div");
     modal.classList.add("modal", "active");
+    modal.setAttribute("ld-tag-modal", "");
     modal.innerHTML = `
-      <div class="modal-overlay" aria-label="Close"></div>
-      <div class="modal-container">
+      <div class="modal-overlay"></div>
+      <div class="modal-container" role="dialog" aria-modal="true">
         <div class="modal-header">
           <h2>Tags</h2>
-          <button class="close" aria-label="Close">
+          <button class="close" aria-label="Close dialog">
             <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
                  stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
               <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -37,32 +39,38 @@ class TagModalBehavior extends Behavior {
         </div>
       </div>    
     `;
+    modal.addEventListener("modal:close", this.onClose);
 
-    const tagCloud = document.querySelector(".tag-cloud");
-    const tagCloudContainer = tagCloud.parentElement;
+    modal.tagCloud = document.querySelector(".tag-cloud");
+    modal.tagCloudContainer = modal.tagCloud.parentElement;
 
     const content = modal.querySelector(".content");
-    content.appendChild(tagCloud);
+    content.appendChild(modal.tagCloud);
 
-    const overlay = modal.querySelector(".modal-overlay");
-    const closeButton = modal.querySelector(".close");
-    overlay.addEventListener("click", this.onClose);
-    closeButton.addEventListener("click", this.onClose);
+    document.body.querySelector(".modals").appendChild(modal);
+  }
+}
 
-    this.modal = modal;
-    this.tagCloud = tagCloud;
-    this.tagCloudContainer = tagCloudContainer;
-    document.body.appendChild(modal);
+class TagModalBehavior extends ModalBehavior {
+  destroy() {
+    super.destroy();
+    // Always close on destroy to restore tag cloud to original parent before
+    // turbo caches DOM
+    this.doClose();
   }
 
-  onClose() {
-    if (!this.modal) {
-      return;
-    }
+  doClose() {
+    super.doClose();
+
+    // Restore tag cloud to original parent
+    this.element.tagCloudContainer.appendChild(this.element.tagCloud);
 
-    this.modal.remove();
-    this.tagCloudContainer.appendChild(this.tagCloud);
+    // Try restore focus to tag cloud trigger
+    const restoreFocusElement =
+      document.querySelector("[ld-tag-modal-trigger]") || document.body;
+    restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
   }
 }
 
+registerBehavior("ld-tag-modal-trigger", TagModalTriggerBehavior);
 registerBehavior("ld-tag-modal", TagModalBehavior);
diff --git a/bookmarks/styles/bookmark-details.css b/bookmarks/styles/bookmark-details.css
index 32f5ca0..ba860fb 100644
--- a/bookmarks/styles/bookmark-details.css
+++ b/bookmarks/styles/bookmark-details.css
@@ -36,8 +36,15 @@
     }
   }
 
-  & dl {
-    margin-bottom: 0;
+
+  & .sections section {
+    margin-top: var(--unit-4);
+  }
+
+  & .sections h3 {
+    margin-bottom: var(--unit-2);
+    font-size: var(--font-size);
+    font-weight: bold;
   }
 
   & .assets {
diff --git a/bookmarks/styles/theme/modals.css b/bookmarks/styles/theme/modals.css
index aa0d73a..b5db0fb 100644
--- a/bookmarks/styles/theme/modals.css
+++ b/bookmarks/styles/theme/modals.css
@@ -78,7 +78,7 @@
       margin: 0;
     }
 
-    & button.close {
+    & .close {
       background: none;
       border: none;
       padding: 0;
diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html
index 9de6dc3..e3ab325 100644
--- a/bookmarks/templates/bookmarks/archive.html
+++ b/bookmarks/templates/bookmarks/archive.html
@@ -13,7 +13,7 @@
         <div class="header-controls">
           {% bookmark_search bookmark_list.search mode='archived' %}
           {% include 'bookmarks/bulk_edit/toggle.html' %}
-          <button ld-tag-modal class="btn ml-2 show-md">Tags
+          <button ld-tag-modal-trigger class="btn ml-2 show-md">Tags
           </button>
         </div>
       </div>
@@ -39,12 +39,14 @@
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
-
-    {# Bookmark details #}
-    <turbo-frame id="details-modal" target="_top">
-      {% if details %}
-        {% include 'bookmarks/details/modal.html' %}
-      {% endif %}
-    </turbo-frame>
   </div>
 {% endblock %}
+
+{% block overlays %}
+  {# Bookmark details #}
+  <turbo-frame id="details-modal" target="_top">
+    {% if details %}
+      {% include 'bookmarks/details/modal.html' %}
+    {% endif %}
+  </turbo-frame>
+{% endblock %}
diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html
index e115975..4014764 100644
--- a/bookmarks/templates/bookmarks/bookmark_list.html
+++ b/bookmarks/templates/bookmarks/bookmark_list.html
@@ -6,10 +6,12 @@
   {% include 'bookmarks/empty_bookmarks.html' %}
 {% else %}
   <ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}"
+      role="list" tabindex="-1"
       style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
       data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
     {% for bookmark_item in bookmark_list.items %}
-      <li ld-bookmark-item{% if bookmark_item.css_classes %} class="{{ bookmark_item.css_classes }}"{% endif %}>
+      <li ld-bookmark-item data-bookmark-id="{{ bookmark_item.id }}" role="listitem"
+          {% if bookmark_item.css_classes %}class="{{ bookmark_item.css_classes }}"{% endif %}>
         <div class="content">
           <div class="title">
             <label class="form-checkbox bulk-edit-checkbox">
@@ -78,7 +80,8 @@
             {% endif %}
             {# View link is visible for both owned and shared bookmarks #}
             {% if bookmark_list.show_view_action %}
-              <a href="{{ bookmark_item.details_url }}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
+              <a href="{{ bookmark_item.details_url }}" class="view-action"
+                 data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
             {% endif %}
             {% if bookmark_item.is_editable %}
               {# Bookmark owner actions #}
diff --git a/bookmarks/templates/bookmarks/details/form.html b/bookmarks/templates/bookmarks/details/form.html
index eeeb19e..916a9f9 100644
--- a/bookmarks/templates/bookmarks/details/form.html
+++ b/bookmarks/templates/bookmarks/details/form.html
@@ -40,14 +40,14 @@
   </div>
   {% if details.preview_image_enabled and details.bookmark.preview_image_file %}
     <div class="preview-image">
-      <img src="{% static details.bookmark.preview_image_file %}"/>
+      <img src="{% static details.bookmark.preview_image_file %}" alt=""/>
     </div>
   {% endif %}
-  <dl class="grid columns-2 columns-sm-1 gap-0">
+  <div class="sections grid columns-2 columns-sm-1 gap-0">
     {% if details.is_editable %}
-      <div class="status col-2">
-        <dt>Status</dt>
-        <dd class="d-flex" style="gap: .8rem">
+      <section class="status col-2">
+        <h3>Status</h3>
+        <div class="d-flex" style="gap: .8rem">
           <div class="form-group">
             <label class="form-switch">
               <input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,44 @@
               </label>
             </div>
           {% endif %}
-        </dd>
-      </div>
+        </div>
+      </section>
     {% endif %}
     {% if details.show_files %}
-      <div class="files col-2">
-        <dt>Files</dt>
-        <dd>
+      <section class="files col-2">
+        <h3>Files</h3>
+        <div>
           {% include 'bookmarks/details/assets.html' %}
-        </dd>
-      </div>
+        </div>
+      </section>
     {% endif %}
     {% if details.bookmark.tag_names %}
-      <div class="tags col-1">
-        <dt>Tags</dt>
-        <dd>
+      <section class="tags col-1">
+        <h3 id="details-modal-tags-title">Tags</h3>
+        <div>
           {% for tag_name in details.bookmark.tag_names %}
             <a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
           {% endfor %}
-        </dd>
-      </div>
+        </div>
+      </section>
     {% endif %}
-    <div class="date-added col-1">
-      <dt>Date added</dt>
-      <dd>
+    <section class="date-added col-1">
+      <h3>Date added</h3>
+      <div>
         <span>{{ details.bookmark.date_added }}</span>
-      </dd>
-    </div>
-    {% if details.bookmark.resolved_description %}
-      <div class="description col-2">
-        <dt>Description</dt>
-        <dd>{{ details.bookmark.resolved_description }}</dd>
       </div>
+    </section>
+    {% if details.bookmark.resolved_description %}
+      <section class="description col-2">
+        <h3>Description</h3>
+        <div>{{ details.bookmark.resolved_description }}</div>
+      </section>
     {% endif %}
     {% if details.bookmark.notes %}
-      <div class="notes col-2">
-        <dt>Notes</dt>
-        <dd class="markdown">{% markdown details.bookmark.notes %}</dd>
-      </div>
+      <section class="notes col-2">
+        <h3>Notes</h3>
+        <div class="markdown">{% markdown details.bookmark.notes %}</div>
+      </section>
     {% endif %}
-  </dl>
+  </div>
 </form>
diff --git a/bookmarks/templates/bookmarks/details/modal.html b/bookmarks/templates/bookmarks/details/modal.html
index 87b4c37..e32bb8b 100644
--- a/bookmarks/templates/bookmarks/details/modal.html
+++ b/bookmarks/templates/bookmarks/details/modal.html
@@ -1,21 +1,17 @@
-<div class="modal active bookmark-details"
-     ld-details-modal>
-  <a href="{{ details.close_url }}" data-turbo-frame="details-modal">
-    <div class="modal-overlay" aria-label="Close"></div>
-  </a>
-  <div class="modal-container">
+<div class="modal active bookmark-details" ld-details-modal
+     data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
+  <div class="modal-overlay"></div>
+  <div class="modal-container" role="dialog" aria-modal="true">
     <div class="modal-header">
       <h2>{{ details.bookmark.resolved_title }}</h2>
-      <a href="{{ details.close_url }}" data-turbo-frame="details-modal">
-        <button class="close">
-          <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
-               stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
-            <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
-            <path d="M18 6l-12 12"></path>
-            <path d="M6 6l12 12"></path>
-          </svg>
-        </button>
-      </a>
+      <button class="close" aria-label="Close dialog">
+        <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
+             stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
+          <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
+          <path d="M18 6l-12 12"></path>
+          <path d="M6 6l12 12"></path>
+        </svg>
+      </button>
     </div>
     <div class="modal-body">
       <div class="content">
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html
index b73d8e0..44ad944 100644
--- a/bookmarks/templates/bookmarks/index.html
+++ b/bookmarks/templates/bookmarks/index.html
@@ -13,7 +13,7 @@
         <div class="header-controls">
           {% bookmark_search bookmark_list.search %}
           {% include 'bookmarks/bulk_edit/toggle.html' %}
-          <button ld-tag-modal class="btn ml-2 show-md">Tags</button>
+          <button ld-tag-modal-trigger class="btn ml-2 show-md">Tags</button>
         </div>
       </div>
 
@@ -38,12 +38,14 @@
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
-
-    {# Bookmark details #}
-    <turbo-frame id="details-modal" target="_top">
-      {% if details %}
-        {% include 'bookmarks/details/modal.html' %}
-      {% endif %}
-    </turbo-frame>
   </div>
 {% endblock %}
+
+{% block overlays %}
+  {# Bookmark details #}
+  <turbo-frame id="details-modal" target="_top">
+    {% if details %}
+      {% include 'bookmarks/details/modal.html' %}
+    {% endif %}
+  </turbo-frame>
+{% endblock %}
\ No newline at end of file
diff --git a/bookmarks/templates/bookmarks/layout.html b/bookmarks/templates/bookmarks/layout.html
index 678da39..28a1df9 100644
--- a/bookmarks/templates/bookmarks/layout.html
+++ b/bookmarks/templates/bookmarks/layout.html
@@ -97,5 +97,9 @@
   {% block content %}
   {% endblock %}
 </div>
+<div class="modals">
+  {% block overlays %}
+  {% endblock %}
+</div>
 </body>
 </html>
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html
index 385ef98..fc0577b 100644
--- a/bookmarks/templates/bookmarks/shared.html
+++ b/bookmarks/templates/bookmarks/shared.html
@@ -12,7 +12,7 @@
         <h2>Shared bookmarks</h2>
         <div class="header-controls">
           {% bookmark_search bookmark_list.search mode='shared' %}
-          <button ld-tag-modal class="btn ml-2 show-md">Tags
+          <button ld-tag-modal-trigger class="btn ml-2 show-md">Tags
           </button>
         </div>
       </div>
@@ -43,12 +43,14 @@
         {% include 'bookmarks/tag_cloud.html' %}
       </div>
     </section>
-
-    {# Bookmark details #}
-    <turbo-frame id="details-modal" target="_top">
-      {% if details %}
-        {% include 'bookmarks/details/modal.html' %}
-      {% endif %}
-    </turbo-frame>
   </div>
 {% endblock %}
+
+{% block overlays %}
+  {# Bookmark details #}
+  <turbo-frame id="details-modal" target="_top">
+    {% if details %}
+      {% include 'bookmarks/details/modal.html' %}
+    {% endif %}
+  </turbo-frame>
+{% endblock %}


Test patch:
diff --git a/bookmarks/tests/test_bookmark_archived_view_performance.py b/bookmarks/tests/test_bookmark_archived_view_performance.py
index ea1f0af..8dc5d94 100644
--- a/bookmarks/tests/test_bookmark_archived_view_performance.py
+++ b/bookmarks/tests/test_bookmark_archived_view_performance.py
@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 
 from bookmarks.models import GlobalSettings
-from bookmarks.tests.helpers import BookmarkFactoryMixin
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 
 
 class BookmarkArchivedViewPerformanceTestCase(
-    TransactionTestCase, BookmarkFactoryMixin
+    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
 ):
 
     def setUp(self) -> None:
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
         context = CaptureQueriesContext(self.get_connection())
         with context:
             response = self.client.get(reverse("bookmarks:archived"))
-            self.assertContains(
-                response, "<li ld-bookmark-item>", num_initial_bookmarks
-            )
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(len(list_items), num_initial_bookmarks)
 
         number_of_queries = context.final_queries
 
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
         # assert num queries doesn't increase
         with self.assertNumQueries(number_of_queries):
             response = self.client.get(reverse("bookmarks:archived"))
-            self.assertContains(
-                response,
-                "<li ld-bookmark-item>",
-                num_initial_bookmarks + num_additional_bookmarks,
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(
+                len(list_items), num_initial_bookmarks + num_additional_bookmarks
             )
diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py
index 5a89739..ebab239 100644
--- a/bookmarks/tests/test_bookmark_details_modal.py
+++ b/bookmarks/tests/test_bookmark_details_modal.py
@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         modal = soup.find("turbo-frame", {"id": "details-modal"})
         return modal
 
-    def find_section(self, soup, section_name):
-        dt = soup.find("dt", string=section_name)
-        dd = dt.find_next_sibling("dd") if dt else None
-        return dd
+    def find_section_content(self, soup, section_name):
+        h3 = soup.find("h3", string=section_name)
+        content = h3.find_next_sibling("div") if h3 else None
+        return content
 
-    def get_section(self, soup, section_name):
-        dd = self.find_section(soup, section_name)
-        self.assertIsNotNone(dd)
-        return dd
+    def get_section_content(self, soup, section_name):
+        content = self.find_section_content(soup, section_name)
+        self.assertIsNotNone(content)
+        return content
 
     def find_weblink(self, soup, url):
         return soup.find("a", {"class": "weblink", "href": url})
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         # sharing disabled
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Status")
+        section = self.get_section_content(soup, "Status")
 
         archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
         self.assertIsNotNone(archived)
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
 
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Status")
+        section = self.get_section_content(soup, "Status")
 
         archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
         self.assertIsNotNone(archived)
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         # unchecked
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Status")
+        section = self.get_section_content(soup, "Status")
 
         archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
         self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         # checked
         bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Status")
+        section = self.get_section_content(soup, "Status")
 
         archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
         self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         # own bookmark
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
-        section = self.find_section(soup, "Status")
+        section = self.find_section_content(soup, "Status")
         self.assertIsNotNone(section)
 
         # other user's bookmark
         other_user = self.setup_user(enable_sharing=True)
         bookmark = self.setup_bookmark(user=other_user, shared=True)
         soup = self.get_shared_details_modal(bookmark)
-        section = self.find_section(soup, "Status")
+        section = self.find_section_content(soup, "Status")
         self.assertIsNone(section)
 
         # guest user
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         other_user.profile.save()
         bookmark = self.setup_bookmark(user=other_user, shared=True)
         soup = self.get_shared_details_modal(bookmark)
-        section = self.find_section(soup, "Status")
+        section = self.find_section_content(soup, "Status")
         self.assertIsNone(section)
 
     def test_date_added(self):
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Date added")
+        section = self.get_section_content(soup, "Date added")
 
         expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
         date = section.find("span", string=expected_date)
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
 
-        section = self.find_section(soup, "Tags")
+        section = self.find_section_content(soup, "Tags")
         self.assertIsNone(section)
 
         # with tags
         bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Tags")
+        section = self.get_section_content(soup, "Tags")
 
         for tag in bookmark.tags.all():
             tag_link = section.find("a", string=f"#{tag.name}")
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark(description="")
         soup = self.get_index_details_modal(bookmark)
 
-        section = self.find_section(soup, "Description")
+        section = self.find_section_content(soup, "Description")
         self.assertIsNone(section)
 
         # with description
         bookmark = self.setup_bookmark(description="Test description")
         soup = self.get_index_details_modal(bookmark)
 
-        section = self.get_section(soup, "Description")
+        section = self.get_section_content(soup, "Description")
         self.assertEqual(section.text.strip(), bookmark.description)
 
     def test_notes(self):
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark()
         soup = self.get_index_details_modal(bookmark)
 
-        section = self.find_section(soup, "Notes")
+        section = self.find_section_content(soup, "Notes")
         self.assertIsNone(section)
 
         # with notes
         bookmark = self.setup_bookmark(notes="Test notes")
         soup = self.get_index_details_modal(bookmark)
 
-        section = self.get_section(soup, "Notes")
+        section = self.get_section_content(soup, "Notes")
         self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
 
     def test_edit_link(self):
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark()
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.find_section(soup, "Files")
+        section = self.find_section_content(soup, "Files")
         self.assertIsNone(section)
 
     @override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark()
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.find_section(soup, "Files")
+        section = self.find_section_content(soup, "Files")
         self.assertIsNotNone(section)
 
     @override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         bookmark = self.setup_bookmark()
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Files")
+        section = self.get_section_content(soup, "Files")
         asset_list = section.find("div", {"class": "assets"})
         self.assertIsNone(asset_list)
 
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         self.setup_asset(bookmark)
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Files")
+        section = self.get_section_content(soup, "Files")
         asset_list = section.find("div", {"class": "assets"})
         self.assertIsNotNone(asset_list)
 
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         ]
 
         soup = self.get_index_details_modal(bookmark)
-        section = self.get_section(soup, "Files")
+        section = self.get_section_content(soup, "Files")
         asset_list = section.find("div", {"class": "assets"})
 
         for asset in assets:
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
 
         # no pending asset
         soup = self.get_index_details_modal(bookmark)
-        files_section = self.find_section(soup, "Files")
+        files_section = self.find_section_content(soup, "Files")
         create_button = files_section.find(
             "button", string=re.compile("Create HTML snapshot")
         )
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
         asset.save()
 
         soup = self.get_index_details_modal(bookmark)
-        files_section = self.find_section(soup, "Files")
+        files_section = self.find_section_content(soup, "Files")
         create_button = files_section.find(
             "button", string=re.compile("Create HTML snapshot")
         )
diff --git a/bookmarks/tests/test_bookmark_index_view_performance.py b/bookmarks/tests/test_bookmark_index_view_performance.py
index 8f84469..3dcaed3 100644
--- a/bookmarks/tests/test_bookmark_index_view_performance.py
+++ b/bookmarks/tests/test_bookmark_index_view_performance.py
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 
 from bookmarks.models import GlobalSettings
-from bookmarks.tests.helpers import BookmarkFactoryMixin
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 
 
-class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
+class BookmarkIndexViewPerformanceTestCase(
+    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
+):
 
     def setUp(self) -> None:
         user = self.get_or_create_test_user()
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
         context = CaptureQueriesContext(self.get_connection())
         with context:
             response = self.client.get(reverse("bookmarks:index"))
-            self.assertContains(
-                response, "<li ld-bookmark-item>", num_initial_bookmarks
-            )
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(len(list_items), num_initial_bookmarks)
 
         number_of_queries = context.final_queries
 
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
         # assert num queries doesn't increase
         with self.assertNumQueries(number_of_queries):
             response = self.client.get(reverse("bookmarks:index"))
-            self.assertContains(
-                response,
-                "<li ld-bookmark-item>",
-                num_initial_bookmarks + num_additional_bookmarks,
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(
+                len(list_items), num_initial_bookmarks + num_additional_bookmarks
             )
diff --git a/bookmarks/tests/test_bookmark_shared_view_performance.py b/bookmarks/tests/test_bookmark_shared_view_performance.py
index 872340d..45190d5 100644
--- a/bookmarks/tests/test_bookmark_shared_view_performance.py
+++ b/bookmarks/tests/test_bookmark_shared_view_performance.py
@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
 from django.urls import reverse
 
 from bookmarks.models import GlobalSettings
-from bookmarks.tests.helpers import BookmarkFactoryMixin
+from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
 
 
-class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryMixin):
+class BookmarkSharedViewPerformanceTestCase(
+    TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
+):
 
     def setUp(self) -> None:
         user = self.get_or_create_test_user()
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
         context = CaptureQueriesContext(self.get_connection())
         with context:
             response = self.client.get(reverse("bookmarks:shared"))
-            self.assertContains(
-                response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks
-            )
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(len(list_items), num_initial_bookmarks)
 
         number_of_queries = context.final_queries
 
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
         # assert num queries doesn't increase
         with self.assertNumQueries(number_of_queries):
             response = self.client.get(reverse("bookmarks:shared"))
-            self.assertContains(
-                response,
-                '<li ld-bookmark-item class="shared">',
-                num_initial_bookmarks + num_additional_bookmarks,
+            html = response.content.decode("utf-8")
+            soup = self.make_soup(html)
+            list_items = soup.select("li[ld-bookmark-item]")
+            self.assertEqual(
+                len(list_items), num_initial_bookmarks + num_additional_bookmarks
             )
diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py
index 5b24914..72901da 100644
--- a/bookmarks/tests/test_bookmarks_list_template.py
+++ b/bookmarks/tests/test_bookmarks_list_template.py
@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
         details_url = base_url + f"?details={bookmark.id}"
         self.assertInHTML(
             f"""
-                <a href="{details_url}" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
+                <a href="{details_url}" class="view-action" data-turbo-action="replace" data-turbo-frame="details-modal">View</a>
             """,
             html,
             count=count,
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
     def test_should_reflect_unread_state_as_css_class(self):
         self.setup_bookmark(unread=True)
         html = self.render_template()
+        soup = self.make_soup(html)
 
-        self.assertIn('<li ld-bookmark-item class="unread">', html)
+        list_item = soup.select_one("li[ld-bookmark-item]")
+        self.assertIsNotNone(list_item)
+        self.assertListEqual(["unread"], list_item["class"])
 
     def test_should_reflect_shared_state_as_css_class(self):
         profile = self.get_or_create_test_user().profile
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
         self.setup_bookmark(shared=True)
         html = self.render_template()
+        soup = self.make_soup(html)
 
-        self.assertIn('<li ld-bookmark-item class="shared">', html)
+        list_item = soup.select_one("li[ld-bookmark-item]")
+        self.assertIsNotNone(list_item)
+        self.assertListEqual(["shared"], list_item["class"])
 
     def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
         profile = self.get_or_create_test_user().profile
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
 
         self.setup_bookmark(unread=True, shared=True)
         html = self.render_template()
+        soup = self.make_soup(html)
 
-        self.assertIn('<li ld-bookmark-item class="unread shared">', html)
+        list_item = soup.select_one("li[ld-bookmark-item]")
+        self.assertIsNotNone(list_item)
+        self.assertListEqual(["unread", "shared"], list_item["class"])
 
     def test_show_bookmark_actions_for_owned_bookmarks(self):
         bookmark = self.setup_bookmark()