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";
+import { registerBehavior } from "./index";
+import { isKeyboardActive } from "./focus-utils";
+import { ModalBehavior } from "./modal";
-class DetailsModalBehavior extends Behavior {
- constructor(element) {
- super(element);
+class DetailsModalBehavior extends ModalBehavior {
+ doClose() {
+ super.doClose();
- this.onClose = this.onClose.bind(this);
- this.onKeyDown = this.onKeyDown.bind(this);
+ // Navigate to close URL
+ const closeUrl = this.element.dataset.closeUrl;
+ Turbo.visit(closeUrl, {
+ action: "replace",
+ frame: "details-modal",
+ });
- this.overlayLink = element.querySelector("a:has(.modal-overlay)");
- this.buttonLink = element.querySelector("a:has(button.close)");
+ // 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;
- 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 },
- );
+ 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 = `
-
-
+
+
`;
+ 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);
-
- 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);
+ document.body.querySelector(".modals").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();
+ }
+
+ 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);
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 @@
@@ -39,12 +39,14 @@
{% include 'bookmarks/tag_cloud.html' %}
-
- {# Bookmark details #}
-
- {% if details %}
- {% include 'bookmarks/details/modal.html' %}
- {% endif %}
-
{% endblock %}
+
+{% block overlays %}
+ {# Bookmark details #}
+
+ {% if details %}
+ {% include 'bookmarks/details/modal.html' %}
+ {% endif %}
+
+{% 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 %}
{% for bookmark_item in bookmark_list.items %}
- -
+
-
{% if details.preview_image_enabled and details.bookmark.preview_image_file %}
-

+
{% endif %}
-
+
{% if details.is_editable %}
-
+
{% endif %}
{% if details.show_files %}
-
-
- Files
-
-
+
+
Files
+
{% include 'bookmarks/details/assets.html' %}
-
-
+
+
{% endif %}
{% if details.bookmark.tag_names %}
-
+
{% endif %}
-
-
- Date added
-
-
+
+
Date added
+
{{ details.bookmark.date_added }}
-
-
- {% if details.bookmark.resolved_description %}
-
-
- Description
- - {{ details.bookmark.resolved_description }}
+
+ {% if details.bookmark.resolved_description %}
+
+ Description
+ {{ details.bookmark.resolved_description }}
+
{% endif %}
{% if details.bookmark.notes %}
-
-
- Notes
- - {% markdown details.bookmark.notes %}
-
+
+ Notes
+ {% markdown details.bookmark.notes %}
+
{% endif %}
-
+
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 @@
-
-
-
-
-
+
+
+
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 @@
@@ -38,12 +38,14 @@
{% include 'bookmarks/tag_cloud.html' %}
-
- {# Bookmark details #}
-
- {% if details %}
- {% include 'bookmarks/details/modal.html' %}
- {% endif %}
-
{% endblock %}
+
+{% block overlays %}
+ {# Bookmark details #}
+
+ {% if details %}
+ {% include 'bookmarks/details/modal.html' %}
+ {% endif %}
+
+{% 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 %}
+
+ {% block overlays %}
+ {% endblock %}
+