Use modal dialog for confirming actions (#1168)

* Use modal dialog for confirming actions

* cleanup unused state
This commit is contained in:
Sascha Ißbrücker
2025-08-22 09:57:31 +02:00
committed by GitHub
parent 8f61fbd04a
commit 3804640574
16 changed files with 236 additions and 136 deletions

View File

@@ -1,79 +1,173 @@
import { Behavior, registerBehavior } from "./index";
import { FocusTrapController, isKeyboardActive } from "./focus-utils";
let confirmId = 0;
function nextConfirmId() {
return `confirm-${confirmId++}`;
}
class ConfirmButtonBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
this.element.addEventListener("click", this.onClick);
}
destroy() {
this.reset();
if (this.opened) {
this.close();
}
this.element.removeEventListener("click", this.onClick);
}
onClick(event) {
event.preventDefault();
Behavior.interacting = true;
const container = document.createElement("span");
container.className = "confirmation";
const icon = this.element.getAttribute("ld-confirm-icon");
if (icon) {
const iconElement = document.createElementNS(
"http://www.w3.org/2000/svg",
"svg",
);
iconElement.style.width = "16px";
iconElement.style.height = "16px";
iconElement.innerHTML = `<use xlink:href="#${icon}"></use>`;
container.append(iconElement);
if (this.opened) {
this.close();
} else {
this.open();
}
}
const question = this.element.getAttribute("ld-confirm-question");
if (question) {
const questionElement = document.createElement("span");
questionElement.innerText = question;
container.append(question);
}
open() {
const dropdown = document.createElement("div");
dropdown.className = "dropdown confirm-dropdown active";
const buttonClasses = Array.from(this.element.classList.values())
.filter((cls) => cls.startsWith("btn"))
.join(" ");
const confirmId = nextConfirmId();
const questionId = `${confirmId}-question`;
const cancelButton = document.createElement(this.element.nodeName);
const menu = document.createElement("div");
menu.className = "menu with-arrow";
menu.role = "alertdialog";
menu.setAttribute("aria-modal", "true");
menu.setAttribute("aria-labelledby", questionId);
menu.addEventListener("keydown", this.onMenuKeyDown.bind(this));
const question = document.createElement("span");
question.id = questionId;
question.textContent =
this.element.getAttribute("ld-confirm-question") || "Are you sure?";
question.style.fontWeight = "bold";
const cancelButton = document.createElement("button");
cancelButton.textContent = "Cancel";
cancelButton.type = "button";
cancelButton.innerText = question ? "No" : "Cancel";
cancelButton.className = `${buttonClasses} mr-1`;
cancelButton.addEventListener("click", this.reset.bind(this));
cancelButton.className = "btn";
cancelButton.tabIndex = 0;
cancelButton.addEventListener("click", () => this.close());
const confirmButton = document.createElement(this.element.nodeName);
confirmButton.type = this.element.type;
const confirmButton = document.createElement("button");
confirmButton.textContent = "Confirm";
confirmButton.type = "submit";
confirmButton.name = this.element.name;
confirmButton.value = this.element.value;
confirmButton.innerText = question ? "Yes" : "Confirm";
confirmButton.className = buttonClasses;
confirmButton.addEventListener("click", this.reset.bind(this));
confirmButton.className = "btn btn-error";
confirmButton.addEventListener("click", () => this.confirm());
container.append(cancelButton, confirmButton);
this.container = container;
const arrow = document.createElement("div");
arrow.className = "menu-arrow";
this.element.before(container);
this.element.classList.add("d-none");
menu.append(question, cancelButton, confirmButton, arrow);
dropdown.append(menu);
document.body.append(dropdown);
this.positionController = new AnchorPositionController(this.element, menu);
this.focusTrap = new FocusTrapController(menu);
this.dropdown = dropdown;
this.opened = true;
}
reset() {
setTimeout(() => {
Behavior.interacting = false;
if (this.container) {
this.container.remove();
this.container = null;
onMenuKeyDown(event) {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
this.close();
}
this.element.classList.remove("d-none");
}
confirm() {
this.element.closest("form").requestSubmit(this.element);
this.close();
}
close() {
if (!this.opened) return;
this.positionController.destroy();
this.focusTrap.destroy();
this.dropdown.remove();
this.element.focus({ focusVisible: isKeyboardActive() });
this.opened = false;
}
}
class AnchorPositionController {
constructor(anchor, overlay) {
this.anchor = anchor;
this.overlay = overlay;
this.handleScroll = this.handleScroll.bind(this);
window.addEventListener("scroll", this.handleScroll, { capture: true });
this.updatePosition();
}
handleScroll() {
if (this.debounce) {
return;
}
this.debounce = true;
requestAnimationFrame(() => {
this.updatePosition();
this.debounce = false;
});
}
updatePosition() {
const anchorRect = this.anchor.getBoundingClientRect();
const overlayRect = this.overlay.getBoundingClientRect();
const bufferX = 10;
const bufferY = 30;
let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2;
const initialLeft = left;
const overflowLeft = left < bufferX;
const overflowRight =
left + overlayRect.width > window.innerWidth - bufferX;
if (overflowLeft) {
left = bufferX;
} else if (overflowRight) {
left = window.innerWidth - overlayRect.width - bufferX;
}
const delta = initialLeft - left;
this.overlay.style.setProperty("--arrow-offset", `${delta}px`);
let top = anchorRect.bottom;
const overflowBottom =
top + overlayRect.height > window.innerHeight - bufferY;
if (overflowBottom) {
top = anchorRect.top - overlayRect.height;
this.overlay.classList.remove("top-aligned");
this.overlay.classList.add("bottom-aligned");
} else {
this.overlay.classList.remove("bottom-aligned");
this.overlay.classList.add("top-aligned");
}
this.overlay.style.left = `${left}px`;
this.overlay.style.top = `${top}px`;
}
destroy() {
window.removeEventListener("scroll", this.handleScroll, { capture: true });
}
}
registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -93,6 +93,12 @@ document.addEventListener("turbo:load", () => {
return;
}
// Ignore if there is a modal dialog, which should handle its own focus
const modal = document.querySelector("[aria-modal='true']");
if (modal) {
return;
}
// Check if there is an explicit focus target for the next page load
for (const target of afterPageLoadFocusTarget) {
const element = document.querySelector(target);

View File

@@ -54,8 +54,6 @@ export class Behavior {
destroy() {}
}
Behavior.interacting = false;
export function registerBehavior(name, behavior) {
behaviorRegistry[name] = behavior;
}

View File

@@ -23,32 +23,22 @@ export class ModalBehavior extends Behavior {
this.closeButton.removeEventListener("click", this.onClose);
document.removeEventListener("keydown", this.onKeyDown);
this.clearInert();
this.removeScrollLock();
this.focusTrap.destroy();
}
init() {
this.setupInert();
this.setupScrollLock();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() {
// Inert all other elements on the page
document
.querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
setupScrollLock() {
document.body.classList.add("scroll-lock");
}
clearInert() {
// Clear inert attribute from all elements to allow focus outside the modal again
document
.querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
removeScrollLock() {
document.body.classList.remove("scroll-lock");
}
@@ -85,7 +75,7 @@ export class ModalBehavior extends Behavior {
doClose() {
this.element.remove();
this.clearInert();
this.removeScrollLock();
this.element.dispatchEvent(new CustomEvent("modal:close"));
}
}

View File

@@ -31,22 +31,17 @@
}
/* Confirm button component */
span.confirmation {
.confirm-dropdown.active {
position: fixed;
z-index: 500;
& .menu {
position: fixed;
display: flex;
align-items: baseline;
gap: var(--unit-1);
color: var(--error-color) !important;
svg {
align-self: center;
}
.btn.btn-link {
color: var(--error-color) !important;
&:hover {
text-decoration: underline;
}
flex-direction: column;
box-sizing: border-box;
gap: var(--unit-2);
padding: var(--unit-2);
}
}

View File

@@ -87,4 +87,43 @@
border-bottom: solid 1px var(--secondary-border-color);
margin: var(--unit-2) 0;
}
&.with-arrow {
overflow: visible;
--arrow-size: 16px;
--arrow-offset: 0px;
.menu-arrow {
display: block;
position: absolute;
inset-inline-start: calc(50% + var(--arrow-offset));
top: 0;
width: var(--arrow-size);
height: var(--arrow-size);
translate: -50% -50%;
rotate: 45deg;
background: inherit;
border: inherit;
clip-path: polygon(0 0, 0 100%, 100% 0);
}
&.top-aligned {
transform: translateY(
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm))
);
}
&.bottom-aligned {
transform: translateY(
calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1)
);
.menu-arrow {
top: auto;
bottom: 0;
rotate: 225deg;
translate: -50% 50%;
}
}
}
}

View File

@@ -106,7 +106,6 @@
& .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right;
}
}

View File

@@ -120,7 +120,7 @@
{% if bookmark_item.show_mark_as_read %}
<button type="submit" name="mark_as_read" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>
@@ -130,7 +130,7 @@
{% if bookmark_item.show_unshare %}
<button type="submit" name="unshare" value="{{ bookmark_item.id }}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>

View File

@@ -32,7 +32,7 @@
<input type="hidden" name="disable_turbo" value="true">
<button ld-confirm-button class="btn btn-error btn-wide"
type="submit" name="remove" value="{{ details.bookmark.id }}">
Delete...
Delete
</button>
</form>
</div>

View File

@@ -18,18 +18,6 @@
<path d="M21 6l0 13"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-read" 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="M3 19a9 9 0 0 1 9 0a9 9 0 0 1 5.899 -1.096"></path>
<path d="M3 6a9 9 0 0 1 2.114 -.884m3.8 -.21c1.07 .17 2.116 .534 3.086 1.094a9 9 0 0 1 9 0"></path>
<path d="M3 6v13"></path>
<path d="M12 6v2m0 4v7"></path>
<path d="M21 6v11"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-share" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">
@@ -41,18 +29,6 @@
<path d="M8.7 13.3l6.6 3.4"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-unshare" 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="M6 12m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M18 6m-3 0a3 3 0 1 0 6 0a3 3 0 1 0 -6 0"></path>
<path d="M15.861 15.896a3 3 0 0 0 4.265 4.22m.578 -3.417a3.012 3.012 0 0 0 -1.507 -1.45"></path>
<path d="M8.7 10.7l1.336 -.688m2.624 -1.352l2.64 -1.36"></path>
<path d="M8.7 13.3l6.6 3.4"></path>
<path d="M3 3l18 18"></path>
</symbol>
</svg>
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="ld-icon-note" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" fill="none"
stroke-linecap="round" stroke-linejoin="round">

View File

@@ -501,7 +501,7 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin
modal = self.get_index_details_modal(bookmark)
delete_button = modal.find("button", {"type": "submit", "name": "remove"})
self.assertIsNotNone(delete_button)
self.assertEqual("Delete...", delete_button.text.strip())
self.assertEqual("Delete", delete_button.text.strip())
self.assertEqual(str(bookmark.id), delete_button["value"])
form = delete_button.find_parent("form")

View File

@@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f"""
<button type="submit" name="unshare" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-unshare" ld-confirm-question="Unshare?">
ld-confirm-button ld-confirm-question="Unshare?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-share"></use>
</svg>
@@ -247,7 +247,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin):
f"""
<button type="submit" name="mark_as_read" value="{bookmark.id}"
class="btn btn-link btn-sm btn-icon"
ld-confirm-button ld-confirm-icon="ld-icon-read" ld-confirm-question="Mark as read?">
ld-confirm-button ld-confirm-question="Mark as read?">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16">
<use xlink:href="#ld-icon-unread"></use>
</svg>

View File

@@ -140,8 +140,8 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Delete bookmark, verify return url
with self.page.expect_navigation(url=self.live_server_url + url):
details_modal.get_by_text("Delete...").click()
details_modal.get_by_text("Confirm").click()
details_modal.get_by_text("Delete").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# verify bookmark is deleted
self.locate_bookmark(bookmark.title)
@@ -173,7 +173,7 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase):
# Remove snapshot
asset_list.get_by_text("Remove", exact=False).click()
asset_list.get_by_text("Confirm", exact=False).click()
self.locate_confirm_dialog().get_by_text("Confirm", exact=False).click()
# Snapshot is removed
expect(snapshot).not_to_be_visible()

View File

@@ -46,7 +46,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
@@ -84,7 +84,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
@@ -122,7 +122,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
@@ -160,7 +160,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
@@ -291,7 +291,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
# Execute bulk action
self.select_bulk_action("Mark as unread")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()
@@ -323,7 +323,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
# Wait until bookmark list is updated (old reference becomes invisible)
expect(bookmark_list).not_to_be_visible()

View File

@@ -123,7 +123,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.index"), p)
self.locate_bookmark("Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Bookmark 2").get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -140,7 +140,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark("Bookmark 2")).to_have_class("unread")
self.locate_bookmark("Bookmark 2").get_by_text("Unread").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("unread")
self.assertReloads(0)
@@ -156,7 +156,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
expect(self.locate_bookmark("Bookmark 2")).to_have_class("shared")
self.locate_bookmark("Bookmark 2").get_by_text("Shared").click()
self.locate_bookmark("Bookmark 2").get_by_text("Yes").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
expect(self.locate_bookmark("Bookmark 2")).not_to_have_class("shared")
self.assertReloads(0)
@@ -173,7 +173,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click()
self.select_bulk_action("Archive")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -191,7 +191,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Bookmark 1", "Bookmark 3"])
self.assertVisibleTags(["Tag 1", "Tag 3"])
@@ -216,7 +216,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.archived"), p)
self.locate_bookmark("Archived Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("Archived Bookmark 2").get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -234,7 +234,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click()
self.select_bulk_action("Unarchive")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -252,7 +252,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
).click()
self.select_bulk_action("Delete")
self.locate_bulk_edit_bar().get_by_text("Execute").click()
self.locate_bulk_edit_bar().get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(["Archived Bookmark 1", "Archived Bookmark 3"])
self.assertVisibleTags(["Archived Tag 1", "Archived Tag 3"])
@@ -293,7 +293,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase):
self.open(reverse("linkding:bookmarks.shared"), p)
self.locate_bookmark("My Bookmark 2").get_by_text("Remove").click()
self.locate_bookmark("My Bookmark 2").get_by_text("Confirm").click()
self.locate_confirm_dialog().get_by_text("Confirm").click()
self.assertVisibleBookmarks(
[

View File

@@ -92,3 +92,6 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin):
).click()
else:
self.page.locator("nav").get_by_text(main_menu_item, exact=True).click()
def locate_confirm_dialog(self):
return self.page.locator(".dropdown.confirm-dropdown")