Improve accessibility of modal dialogs (#974)

* improve details modal accessibility

* improve tag modal accessibility

* fix overlays in archive and shared pages

* update tests

* use buttons for closing dialogs

* replace description list

* hide preview image from screen readers

* update tests
This commit is contained in:
Sascha Ißbrücker
2025-02-02 00:28:17 +01:00
committed by GitHub
parent 2973812626
commit 17442eeb9a
18 changed files with 369 additions and 217 deletions

View File

@@ -1,61 +1,28 @@
import { Behavior, registerBehavior } from "./index"; import { registerBehavior } from "./index";
import { isKeyboardActive } from "./focus-utils";
import { ModalBehavior } from "./modal";
class DetailsModalBehavior extends Behavior { class DetailsModalBehavior extends ModalBehavior {
constructor(element) { doClose() {
super(element); super.doClose();
this.onClose = this.onClose.bind(this); // Navigate to close URL
this.onKeyDown = this.onKeyDown.bind(this); const closeUrl = this.element.dataset.closeUrl;
Turbo.visit(closeUrl, {
action: "replace",
frame: "details-modal",
});
this.overlayLink = element.querySelector("a:has(.modal-overlay)"); // Try restore focus to view details to view details link of respective bookmark
this.buttonLink = element.querySelector("a:has(button.close)"); 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;
this.overlayLink.addEventListener("click", this.onClose); restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
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 },
);
} }
} }

View File

@@ -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();
}
}
}
}

View File

@@ -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"));
}
}

View File

@@ -1,29 +1,31 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class TagModalBehavior extends Behavior { class TagModalTriggerBehavior extends Behavior {
constructor(element) { constructor(element) {
super(element); super(element);
this.onClick = this.onClick.bind(this); this.onClick = this.onClick.bind(this);
this.onClose = this.onClose.bind(this);
element.addEventListener("click", this.onClick); element.addEventListener("click", this.onClick);
} }
destroy() { destroy() {
this.onClose();
this.element.removeEventListener("click", this.onClick); this.element.removeEventListener("click", this.onClick);
} }
onClick() { onClick() {
// Creates a new modal and teleports the tag cloud into it
const modal = document.createElement("div"); const modal = document.createElement("div");
modal.classList.add("modal", "active"); modal.classList.add("modal", "active");
modal.setAttribute("ld-tag-modal", "");
modal.innerHTML = ` modal.innerHTML = `
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-overlay"></div>
<div class="modal-container"> <div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header"> <div class="modal-header">
<h2>Tags</h2> <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" <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"> stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
@@ -37,32 +39,38 @@ class TagModalBehavior extends Behavior {
</div> </div>
</div> </div>
`; `;
modal.addEventListener("modal:close", this.onClose);
const tagCloud = document.querySelector(".tag-cloud"); modal.tagCloud = document.querySelector(".tag-cloud");
const tagCloudContainer = tagCloud.parentElement; modal.tagCloudContainer = modal.tagCloud.parentElement;
const content = modal.querySelector(".content"); const content = modal.querySelector(".content");
content.appendChild(tagCloud); content.appendChild(modal.tagCloud);
const overlay = modal.querySelector(".modal-overlay"); document.body.querySelector(".modals").appendChild(modal);
const closeButton = modal.querySelector(".close");
overlay.addEventListener("click", this.onClose);
closeButton.addEventListener("click", this.onClose);
this.modal = modal;
this.tagCloud = tagCloud;
this.tagCloudContainer = tagCloudContainer;
document.body.appendChild(modal);
}
onClose() {
if (!this.modal) {
return;
}
this.modal.remove();
this.tagCloudContainer.appendChild(this.tagCloud);
} }
} }
class TagModalBehavior extends ModalBehavior {
destroy() {
super.destroy();
// Always close on destroy to restore tag cloud to original parent before
// turbo caches DOM
this.doClose();
}
doClose() {
super.doClose();
// Restore tag cloud to original parent
this.element.tagCloudContainer.appendChild(this.element.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); registerBehavior("ld-tag-modal", TagModalBehavior);

View File

@@ -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 { & .assets {

View File

@@ -78,7 +78,7 @@
margin: 0; margin: 0;
} }
& button.close { & .close {
background: none; background: none;
border: none; border: none;
padding: 0; padding: 0;

View File

@@ -13,7 +13,7 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search mode='archived' %} {% bookmark_search bookmark_list.search mode='archived' %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% 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> </button>
</div> </div>
</div> </div>
@@ -39,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -6,10 +6,12 @@
{% include 'bookmarks/empty_bookmarks.html' %} {% include 'bookmarks/empty_bookmarks.html' %}
{% else %} {% else %}
<ul class="bookmark-list{% if bookmark_list.show_notes %} show-notes{% endif %}" <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 }};" style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};"
data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> data-bookmarks-total="{{ bookmark_list.bookmarks_total }}">
{% for bookmark_item in bookmark_list.items %} {% 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="content">
<div class="title"> <div class="title">
<label class="form-checkbox bulk-edit-checkbox"> <label class="form-checkbox bulk-edit-checkbox">
@@ -78,7 +80,8 @@
{% endif %} {% endif %}
{# View link is visible for both owned and shared bookmarks #} {# View link is visible for both owned and shared bookmarks #}
{% if bookmark_list.show_view_action %} {% 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 %} {% endif %}
{% if bookmark_item.is_editable %} {% if bookmark_item.is_editable %}
{# Bookmark owner actions #} {# Bookmark owner actions #}

View File

@@ -40,14 +40,14 @@
</div> </div>
{% if details.preview_image_enabled and details.bookmark.preview_image_file %} {% if details.preview_image_enabled and details.bookmark.preview_image_file %}
<div class="preview-image"> <div class="preview-image">
<img src="{% static details.bookmark.preview_image_file %}"/> <img src="{% static details.bookmark.preview_image_file %}" alt=""/>
</div> </div>
{% endif %} {% 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 %} {% if details.is_editable %}
<div class="status col-2"> <section class="status col-2">
<dt>Status</dt> <h3>Status</h3>
<dd class="d-flex" style="gap: .8rem"> <div class="d-flex" style="gap: .8rem">
<div class="form-group"> <div class="form-group">
<label class="form-switch"> <label class="form-switch">
<input ld-auto-submit type="checkbox" name="is_archived" <input ld-auto-submit type="checkbox" name="is_archived"
@@ -71,44 +71,44 @@
</label> </label>
</div> </div>
{% endif %} {% endif %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
{% if details.show_files %} {% if details.show_files %}
<div class="files col-2"> <section class="files col-2">
<dt>Files</dt> <h3>Files</h3>
<dd> <div>
{% include 'bookmarks/details/assets.html' %} {% include 'bookmarks/details/assets.html' %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
{% if details.bookmark.tag_names %} {% if details.bookmark.tag_names %}
<div class="tags col-1"> <section class="tags col-1">
<dt>Tags</dt> <h3 id="details-modal-tags-title">Tags</h3>
<dd> <div>
{% for tag_name in details.bookmark.tag_names %} {% for tag_name in details.bookmark.tag_names %}
<a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a> <a href="{% url 'bookmarks:index' %}?{% add_tag_to_query tag_name %}">{{ tag_name|hash_tag }}</a>
{% endfor %} {% endfor %}
</dd> </div>
</div> </section>
{% endif %} {% endif %}
<div class="date-added col-1"> <section class="date-added col-1">
<dt>Date added</dt> <h3>Date added</h3>
<dd> <div>
<span>{{ details.bookmark.date_added }}</span> <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> </div>
</section>
{% if details.bookmark.resolved_description %}
<section class="description col-2">
<h3>Description</h3>
<div>{{ details.bookmark.resolved_description }}</div>
</section>
{% endif %} {% endif %}
{% if details.bookmark.notes %} {% if details.bookmark.notes %}
<div class="notes col-2"> <section class="notes col-2">
<dt>Notes</dt> <h3>Notes</h3>
<dd class="markdown">{% markdown details.bookmark.notes %}</dd> <div class="markdown">{% markdown details.bookmark.notes %}</div>
</div> </section>
{% endif %} {% endif %}
</dl> </div>
</form> </form>

View File

@@ -1,21 +1,17 @@
<div class="modal active bookmark-details" <div class="modal active bookmark-details" ld-details-modal
ld-details-modal> data-bookmark-id="{{ details.bookmark.id }}" data-close-url="{{ details.close_url }}">
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <div class="modal-overlay"></div>
<div class="modal-overlay" aria-label="Close"></div> <div class="modal-container" role="dialog" aria-modal="true">
</a>
<div class="modal-container">
<div class="modal-header"> <div class="modal-header">
<h2>{{ details.bookmark.resolved_title }}</h2> <h2>{{ details.bookmark.resolved_title }}</h2>
<a href="{{ details.close_url }}" data-turbo-frame="details-modal"> <button class="close" aria-label="Close dialog">
<button class="close"> <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
<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">
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path> <path d="M18 6l-12 12"></path>
<path d="M18 6l-12 12"></path> <path d="M6 6l12 12"></path>
<path d="M6 6l12 12"></path> </svg>
</svg> </button>
</button>
</a>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="content"> <div class="content">

View File

@@ -13,7 +13,7 @@
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search %} {% bookmark_search bookmark_list.search %}
{% include 'bookmarks/bulk_edit/toggle.html' %} {% 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>
</div> </div>
@@ -38,12 +38,14 @@
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -97,5 +97,9 @@
{% block content %} {% block content %}
{% endblock %} {% endblock %}
</div> </div>
<div class="modals">
{% block overlays %}
{% endblock %}
</div>
</body> </body>
</html> </html>

View File

@@ -12,7 +12,7 @@
<h2>Shared bookmarks</h2> <h2>Shared bookmarks</h2>
<div class="header-controls"> <div class="header-controls">
{% bookmark_search bookmark_list.search mode='shared' %} {% 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> </button>
</div> </div>
</div> </div>
@@ -43,12 +43,14 @@
{% include 'bookmarks/tag_cloud.html' %} {% include 'bookmarks/tag_cloud.html' %}
</div> </div>
</section> </section>
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
</div> </div>
{% endblock %} {% endblock %}
{% block overlays %}
{# Bookmark details #}
<turbo-frame id="details-modal" target="_top">
{% if details %}
{% include 'bookmarks/details/modal.html' %}
{% endif %}
</turbo-frame>
{% endblock %}

View File

@@ -5,11 +5,11 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings from bookmarks.models import GlobalSettings
from bookmarks.tests.helpers import BookmarkFactoryMixin from bookmarks.tests.helpers import BookmarkFactoryMixin, HtmlTestMixin
class BookmarkArchivedViewPerformanceTestCase( class BookmarkArchivedViewPerformanceTestCase(
TransactionTestCase, BookmarkFactoryMixin TransactionTestCase, BookmarkFactoryMixin, HtmlTestMixin
): ):
def setUp(self) -> None: def setUp(self) -> None:
@@ -32,9 +32,10 @@ class BookmarkArchivedViewPerformanceTestCase(
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("bookmarks:archived"))
self.assertContains( html = response.content.decode("utf-8")
response, "<li ld-bookmark-item>", num_initial_bookmarks 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 number_of_queries = context.final_queries
@@ -46,8 +47,9 @@ class BookmarkArchivedViewPerformanceTestCase(
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:archived")) response = self.client.get(reverse("bookmarks:archived"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
"<li ld-bookmark-item>", list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -32,15 +32,15 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = soup.find("turbo-frame", {"id": "details-modal"}) modal = soup.find("turbo-frame", {"id": "details-modal"})
return modal return modal
def find_section(self, soup, section_name): def find_section_content(self, soup, section_name):
dt = soup.find("dt", string=section_name) h3 = soup.find("h3", string=section_name)
dd = dt.find_next_sibling("dd") if dt else None content = h3.find_next_sibling("div") if h3 else None
return dd return content
def get_section(self, soup, section_name): def get_section_content(self, soup, section_name):
dd = self.find_section(soup, section_name) content = self.find_section_content(soup, section_name)
self.assertIsNotNone(dd) self.assertIsNotNone(content)
return dd return content
def find_weblink(self, soup, url): def find_weblink(self, soup, url):
return soup.find("a", {"class": "weblink", "href": url}) return soup.find("a", {"class": "weblink", "href": url})
@@ -367,7 +367,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# sharing disabled # sharing disabled
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(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"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived) self.assertIsNotNone(archived)
@@ -383,7 +383,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(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"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertIsNotNone(archived) self.assertIsNotNone(archived)
@@ -395,7 +395,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# unchecked # unchecked
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(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"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertFalse(archived.has_attr("checked")) self.assertFalse(archived.has_attr("checked"))
@@ -407,7 +407,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# checked # checked
bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True) bookmark = self.setup_bookmark(is_archived=True, unread=True, shared=True)
soup = self.get_index_details_modal(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"}) archived = section.find("input", {"type": "checkbox", "name": "is_archived"})
self.assertTrue(archived.has_attr("checked")) self.assertTrue(archived.has_attr("checked"))
@@ -420,14 +420,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# own bookmark # own bookmark
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNotNone(section) self.assertIsNotNone(section)
# other user's bookmark # other user's bookmark
other_user = self.setup_user(enable_sharing=True) other_user = self.setup_user(enable_sharing=True)
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
# guest user # guest user
@@ -436,13 +436,13 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
other_user.profile.save() other_user.profile.save()
bookmark = self.setup_bookmark(user=other_user, shared=True) bookmark = self.setup_bookmark(user=other_user, shared=True)
soup = self.get_shared_details_modal(bookmark) soup = self.get_shared_details_modal(bookmark)
section = self.find_section(soup, "Status") section = self.find_section_content(soup, "Status")
self.assertIsNone(section) self.assertIsNone(section)
def test_date_added(self): def test_date_added(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(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") expected_date = formats.date_format(bookmark.date_added, "DATETIME_FORMAT")
date = section.find("span", string=expected_date) date = section.find("span", string=expected_date)
@@ -453,14 +453,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Tags") section = self.find_section_content(soup, "Tags")
self.assertIsNone(section) self.assertIsNone(section)
# with tags # with tags
bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()]) bookmark = self.setup_bookmark(tags=[self.setup_tag(), self.setup_tag()])
soup = self.get_index_details_modal(bookmark) 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(): for tag in bookmark.tags.all():
tag_link = section.find("a", string=f"#{tag.name}") tag_link = section.find("a", string=f"#{tag.name}")
@@ -473,14 +473,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark(description="") bookmark = self.setup_bookmark(description="")
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Description") section = self.find_section_content(soup, "Description")
self.assertIsNone(section) self.assertIsNone(section)
# with description # with description
bookmark = self.setup_bookmark(description="Test description") bookmark = self.setup_bookmark(description="Test description")
soup = self.get_index_details_modal(bookmark) 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) self.assertEqual(section.text.strip(), bookmark.description)
def test_notes(self): def test_notes(self):
@@ -488,14 +488,14 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Notes") section = self.find_section_content(soup, "Notes")
self.assertIsNone(section) self.assertIsNone(section)
# with notes # with notes
bookmark = self.setup_bookmark(notes="Test notes") bookmark = self.setup_bookmark(notes="Test notes")
soup = self.get_index_details_modal(bookmark) 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>") self.assertEqual(section.decode_contents(), "<p>Test notes</p>")
def test_edit_link(self): def test_edit_link(self):
@@ -568,7 +568,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files") section = self.find_section_content(soup, "Files")
self.assertIsNone(section) self.assertIsNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -576,7 +576,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(bookmark) soup = self.get_index_details_modal(bookmark)
section = self.find_section(soup, "Files") section = self.find_section_content(soup, "Files")
self.assertIsNotNone(section) self.assertIsNotNone(section)
@override_settings(LD_ENABLE_SNAPSHOTS=True) @override_settings(LD_ENABLE_SNAPSHOTS=True)
@@ -585,7 +585,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()
soup = self.get_index_details_modal(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"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNone(asset_list) self.assertIsNone(asset_list)
@@ -594,7 +594,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
self.setup_asset(bookmark) self.setup_asset(bookmark)
soup = self.get_index_details_modal(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"}) asset_list = section.find("div", {"class": "assets"})
self.assertIsNotNone(asset_list) self.assertIsNotNone(asset_list)
@@ -608,7 +608,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
] ]
soup = self.get_index_details_modal(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"}) asset_list = section.find("div", {"class": "assets"})
for asset in assets: for asset in assets:
@@ -738,7 +738,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
# no pending asset # no pending asset
soup = self.get_index_details_modal(bookmark) 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( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )
@@ -749,7 +749,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
asset.save() asset.save()
soup = self.get_index_details_modal(bookmark) 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( create_button = files_section.find(
"button", string=re.compile("Create HTML snapshot") "button", string=re.compile("Create HTML snapshot")
) )

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings 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: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -30,9 +32,10 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("bookmarks:index"))
self.assertContains( html = response.content.decode("utf-8")
response, "<li ld-bookmark-item>", num_initial_bookmarks 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 number_of_queries = context.final_queries
@@ -44,8 +47,9 @@ class BookmarkIndexViewPerformanceTestCase(TransactionTestCase, BookmarkFactoryM
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:index")) response = self.client.get(reverse("bookmarks:index"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
"<li ld-bookmark-item>", list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -5,10 +5,12 @@ from django.test.utils import CaptureQueriesContext
from django.urls import reverse from django.urls import reverse
from bookmarks.models import GlobalSettings 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: def setUp(self) -> None:
user = self.get_or_create_test_user() user = self.get_or_create_test_user()
@@ -31,9 +33,10 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
context = CaptureQueriesContext(self.get_connection()) context = CaptureQueriesContext(self.get_connection())
with context: with context:
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("bookmarks:shared"))
self.assertContains( html = response.content.decode("utf-8")
response, '<li ld-bookmark-item class="shared">', num_initial_bookmarks 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 number_of_queries = context.final_queries
@@ -46,8 +49,9 @@ class BookmarkSharedViewPerformanceTestCase(TransactionTestCase, BookmarkFactory
# assert num queries doesn't increase # assert num queries doesn't increase
with self.assertNumQueries(number_of_queries): with self.assertNumQueries(number_of_queries):
response = self.client.get(reverse("bookmarks:shared")) response = self.client.get(reverse("bookmarks:shared"))
self.assertContains( html = response.content.decode("utf-8")
response, soup = self.make_soup(html)
'<li ld-bookmark-item class="shared">', list_items = soup.select("li[ld-bookmark-item]")
num_initial_bookmarks + num_additional_bookmarks, self.assertEqual(
len(list_items), num_initial_bookmarks + num_additional_bookmarks
) )

View File

@@ -69,7 +69,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
details_url = base_url + f"?details={bookmark.id}" details_url = base_url + f"?details={bookmark.id}"
self.assertInHTML( self.assertInHTML(
f""" 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, html,
count=count, count=count,
@@ -562,8 +562,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
def test_should_reflect_unread_state_as_css_class(self): def test_should_reflect_unread_state_as_css_class(self):
self.setup_bookmark(unread=True) self.setup_bookmark(unread=True)
html = self.render_template() 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): def test_should_reflect_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -572,8 +575,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(shared=True) self.setup_bookmark(shared=True)
html = self.render_template() 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): def test_should_reflect_both_unread_and_shared_state_as_css_class(self):
profile = self.get_or_create_test_user().profile profile = self.get_or_create_test_user().profile
@@ -582,8 +588,11 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
self.setup_bookmark(unread=True, shared=True) self.setup_bookmark(unread=True, shared=True)
html = self.render_template() 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): def test_show_bookmark_actions_for_owned_bookmarks(self):
bookmark = self.setup_bookmark() bookmark = self.setup_bookmark()