Convert tag modal into drawer (#977)

* change tag modal into drawer

* improve scroll handling

* teleport all side bar content

* improve naming

* fix focus trap in filter drawer
This commit is contained in:
Sascha Ißbrücker
2025-02-02 20:42:28 +01:00
committed by GitHub
parent 0d4c47eb81
commit c5a300a435
14 changed files with 197 additions and 125 deletions

View File

@@ -12,13 +12,13 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
def assertSidePanelIsVisible(self): def assertSidePanelIsVisible(self):
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible() expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
expect( expect(
self.page.locator(".bookmarks-page [ld-tag-modal-trigger]") self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).not_to_be_visible() ).not_to_be_visible()
def assertSidePanelIsHidden(self): def assertSidePanelIsHidden(self):
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible() expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
expect( expect(
self.page.locator(".bookmarks-page [ld-tag-modal-trigger]") self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
).to_be_visible() ).to_be_visible()
def test_side_panel_should_be_visible_by_default(self): def test_side_panel_should_be_visible_by_default(self):

View File

@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
from bookmarks.e2e.helpers import LinkdingE2ETestCase from bookmarks.e2e.helpers import LinkdingE2ETestCase
class TagCloudModalE2ETestCase(LinkdingE2ETestCase): class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
def test_show_modal_close_modal(self): def test_show_modal_close_modal(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")]) self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
self.setup_bookmark(tags=[self.setup_tag(name="hiking")]) self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p: with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p) page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible # use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812}) page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal # open drawer
modal_trigger = page.locator(".content-area-header").get_by_role( drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags" "button", name="Filters"
) )
modal_trigger.click() drawer_trigger.click()
# verify modal is visible # verify drawer is visible
modal = page.locator(".modal") drawer = page.locator(".modal.drawer.filter-drawer")
expect(modal).to_be_visible() expect(drawer).to_be_visible()
expect(modal.locator("h2")).to_have_text("Tags") expect(drawer.locator("h2")).to_have_text("Filters")
# close with close button # close with close button
modal.locator("button.close").click() drawer.locator("button.close").click()
expect(modal).to_be_hidden() expect(drawer).to_be_hidden()
# open modal again # open drawer again
modal_trigger.click() drawer_trigger.click()
# close with backdrop # close with backdrop
backdrop = modal.locator(".modal-overlay") backdrop = drawer.locator(".modal-overlay")
backdrop.click(position={"x": 0, "y": 0}) backdrop.click(position={"x": 0, "y": 0})
expect(modal).to_be_hidden() expect(drawer).to_be_hidden()
def test_select_tag(self): def test_select_tag(self):
self.setup_bookmark(tags=[self.setup_tag(name="cooking")]) self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
with sync_playwright() as p: with sync_playwright() as p:
page = self.open(reverse("bookmarks:index"), p) page = self.open(reverse("bookmarks:index"), p)
# use smaller viewport to make tags button visible # use smaller viewport to make filter button visible
page.set_viewport_size({"width": 375, "height": 812}) page.set_viewport_size({"width": 375, "height": 812})
# open tag cloud modal # open tag cloud modal
modal_trigger = page.locator(".content-area-header").get_by_role( drawer_trigger = page.locator(".content-area-header").get_by_role(
"button", name="Tags" "button", name="Filters"
) )
modal_trigger.click() drawer_trigger.click()
# verify tags are displayed # verify tags are displayed
modal = page.locator(".modal") drawer = page.locator(".modal.drawer.filter-drawer")
unselected_tags = modal.locator(".unselected-tags") unselected_tags = drawer.locator(".unselected-tags")
expect(unselected_tags.get_by_text("cooking")).to_be_visible() expect(unselected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("hiking")).to_be_visible() expect(unselected_tags.get_by_text("hiking")).to_be_visible()
# select tag # select tag
unselected_tags.get_by_text("cooking").click() unselected_tags.get_by_text("cooking").click()
# open modal again # open drawer again
modal_trigger.click() drawer_trigger.click()
# verify tag is selected, other tag is not visible anymore # verify tag is selected, other tag is not visible anymore
selected_tags = modal.locator(".selected-tags") selected_tags = drawer.locator(".selected-tags")
expect(selected_tags.get_by_text("cooking")).to_be_visible() expect(selected_tags.get_by_text("cooking")).to_be_visible()
expect(unselected_tags.get_by_text("cooking")).not_to_be_visible() expect(unselected_tags.get_by_text("cooking")).not_to_be_visible()

View File

@@ -0,0 +1,97 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class FilterDrawerTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
this.element.removeEventListener("click", this.onClick);
}
onClick() {
const modal = document.createElement("div");
modal.classList.add("modal", "drawer", "filter-drawer");
modal.setAttribute("ld-filter-drawer", "");
modal.innerHTML = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<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">
<section class="content content-area"></div>
</div>
</div>
`;
document.body.querySelector(".modals").appendChild(modal);
}
}
class FilterDrawerBehavior extends ModalBehavior {
init() {
// Teleport content before creating focus trap, otherwise it will not detect
// focusable content elements
this.teleport();
super.init();
// Add active class to start slide-in animation
this.element.classList.add("active");
}
destroy() {
super.destroy();
// Always close on destroy to restore drawer content to original location
// before turbo caches DOM
this.doClose();
}
mapHeading(container, from, to) {
const headings = container.querySelectorAll(from);
headings.forEach((heading) => {
const newHeading = document.createElement(to);
newHeading.textContent = heading.textContent;
heading.replaceWith(newHeading);
});
}
teleport() {
const content = this.element.querySelector(".content");
const sidePanel = document.querySelector("section.side-panel");
content.append(...sidePanel.children);
this.mapHeading(content, "h2", "h3");
}
teleportBack() {
const sidePanel = document.querySelector("section.side-panel");
const content = this.element.querySelector(".content");
sidePanel.append(...content.children);
this.mapHeading(sidePanel, "h3", "h2");
}
doClose() {
super.doClose();
this.teleportBack();
// Try restore focus to drawer trigger
const restoreFocusElement =
document.querySelector("[ld-filter-drawer-trigger]") || document.body;
restoreFocusElement.focus({ focusVisible: isKeyboardActive() });
}
}
registerBehavior("ld-filter-drawer-trigger", FilterDrawerTriggerBehavior);
registerBehavior("ld-filter-drawer", FilterDrawerBehavior);

View File

@@ -15,10 +15,7 @@ export class ModalBehavior extends Behavior {
this.closeButton.addEventListener("click", this.onClose); this.closeButton.addEventListener("click", this.onClose);
document.addEventListener("keydown", this.onKeyDown); document.addEventListener("keydown", this.onKeyDown);
this.setupInert(); this.init();
this.focusTrap = new FocusTrapController(
element.querySelector(".modal-container"),
);
} }
destroy() { destroy() {
@@ -30,11 +27,20 @@ export class ModalBehavior extends Behavior {
this.focusTrap.destroy(); this.focusTrap.destroy();
} }
init() {
this.setupInert();
this.focusTrap = new FocusTrapController(
this.element.querySelector(".modal-container"),
);
}
setupInert() { setupInert() {
// Inert all other elements on the page // Inert all other elements on the page
document document
.querySelectorAll("body > *:not(.modals)") .querySelectorAll("body > *:not(.modals)")
.forEach((el) => el.setAttribute("inert", "")); .forEach((el) => el.setAttribute("inert", ""));
// Lock scroll on the body
document.body.classList.add("scroll-lock");
} }
clearInert() { clearInert() {
@@ -42,6 +48,8 @@ export class ModalBehavior extends Behavior {
document document
.querySelectorAll("body > *") .querySelectorAll("body > *")
.forEach((el) => el.removeAttribute("inert")); .forEach((el) => el.removeAttribute("inert"));
// Remove scroll lock from the body
document.body.classList.remove("scroll-lock");
} }
onKeyDown(event) { onKeyDown(event) {

View File

@@ -1,76 +0,0 @@
import { Behavior, registerBehavior } from "./index";
import { ModalBehavior } from "./modal";
import { isKeyboardActive } from "./focus-utils";
class TagModalTriggerBehavior extends Behavior {
constructor(element) {
super(element);
this.onClick = this.onClick.bind(this);
element.addEventListener("click", this.onClick);
}
destroy() {
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"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Tags</h2>
<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"></div>
</div>
</div>
`;
modal.addEventListener("modal:close", this.onClose);
modal.tagCloud = document.querySelector(".tag-cloud");
modal.tagCloudContainer = modal.tagCloud.parentElement;
const content = modal.querySelector(".content");
content.appendChild(modal.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);

View File

@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
import "./behaviors/bulk-edit"; import "./behaviors/bulk-edit";
import "./behaviors/clear-button"; import "./behaviors/clear-button";
import "./behaviors/confirm-button"; import "./behaviors/confirm-button";
import "./behaviors/dropdown";
import "./behaviors/form";
import "./behaviors/details-modal"; import "./behaviors/details-modal";
import "./behaviors/dropdown";
import "./behaviors/filter-drawer";
import "./behaviors/form";
import "./behaviors/global-shortcuts"; import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete"; import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete"; import "./behaviors/tag-autocomplete";
import "./behaviors/tag-modal";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte"; export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte"; export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";

View File

@@ -15,7 +15,7 @@
grid-gap: var(--unit-9); grid-gap: var(--unit-9);
} }
[ld-tag-modal-trigger] { [ld-filter-drawer-trigger] {
display: none; display: none;
} }
@@ -24,7 +24,7 @@
display: none; display: none;
} }
[ld-tag-modal-trigger] { [ld-filter-drawer-trigger] {
display: inline-block; display: inline-block;
} }
} }
@@ -38,7 +38,7 @@
display: none; display: none;
} }
[ld-tag-modal-trigger] { [ld-filter-drawer-trigger] {
display: inline-block; display: inline-block;
} }
} }

View File

@@ -2,7 +2,8 @@
/* Content area component */ /* Content area component */
section.content-area { section.content-area {
h2 { h2,
h3 {
font-size: var(--font-size-lg); font-size: var(--font-size-lg);
} }
@@ -14,7 +15,8 @@ section.content-area {
padding-bottom: var(--unit-2); padding-bottom: var(--unit-2);
margin-bottom: var(--unit-4); margin-bottom: var(--unit-4);
h2 { h2,
h3 {
flex: 0 0 auto; flex: 0 0 auto;
line-height: var(--unit-9); line-height: var(--unit-9);
margin: 0; margin: 0;

View File

@@ -10,7 +10,6 @@ html {
font-size: var(--html-font-size); font-size: var(--html-font-size);
line-height: var(--html-line-height); line-height: var(--html-line-height);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
scrollbar-gutter: stable;
} }
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */ /* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */

View File

@@ -62,13 +62,14 @@
gap: var(--unit-4); gap: var(--unit-4);
max-height: 75vh; max-height: 75vh;
max-width: var(--control-width-md); max-width: var(--control-width-md);
padding: var(--unit-6);
width: 100%; width: 100%;
& .modal-header { & .modal-header {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: var(--unit-2); gap: var(--unit-2);
padding: var(--unit-6);
padding-bottom: 0;
color: var(--text-color); color: var(--text-color);
& h2 { & h2 {
@@ -95,10 +96,53 @@
& .modal-body { & .modal-body {
overflow-y: auto; overflow-y: auto;
position: relative; padding: 0 var(--unit-6);
}
& .modal-body:not(:has(+ .modal-footer)) {
margin-bottom: var(--unit-6);
} }
& .modal-footer { & .modal-footer {
padding: var(--unit-6);
padding-top: 0;
text-align: right; text-align: right;
} }
} }
.modal.drawer {
display: block;
& .modal-container {
position: fixed;
top: 0;
right: 0;
width: 400px;
max-width: 100%;
height: 100%;
max-height: 100%;
border: none;
border-left: solid 1px var(--modal-container-border-color);
border-radius: 0;
transform: translateX(100%);
animation: fade-in 0.25s ease 1;
transition: transform 0.25s ease;
}
&.active {
& .modal-container {
transform: translateX(0);
}
}
&.active.closing {
& .modal-container {
animation: fade-out 0.25s ease 1;
transform: translateX(100%);
}
}
}
.scroll-lock {
overflow: hidden !important;
}

View File

@@ -14,8 +14,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-trigger class="btn ml-2">Tags <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</button>
</div> </div>
</div> </div>

View File

@@ -14,7 +14,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-trigger class="btn ml-2">Tags</button> <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</div> </div>
</div> </div>

View File

@@ -13,8 +13,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-trigger class="btn ml-2">Tags <button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
</button>
</div> </div>
</div> </div>

View File

@@ -127,11 +127,11 @@
<div class="form-group"> <div class="form-group">
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox"> <label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
{{ form.collapse_side_panel }} {{ form.collapse_side_panel }}
<i class="form-icon"></i> Collapse tags <i class="form-icon"></i> Collapse side panel
</label> </label>
<div class="form-input-hint"> <div class="form-input-hint">
When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list. When enabled, the tags side panel will be collapsed by default to give more space to the bookmark list.
Instead, the tags can be shown in a modal dialog by clicking the tags button in the bookmark list header. Instead, the tags are shown in an expandable drawer.
</div> </div>
</div> </div>
<div class="form-group"> <div class="form-group">