mirror of
https://github.com/sissbruecker/linkding.git
synced 2025-08-07 10:58:25 +02:00
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:
@@ -12,13 +12,13 @@ class CollapseSidePanelE2ETestCase(LinkdingE2ETestCase):
|
||||
def assertSidePanelIsVisible(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-tag-modal-trigger]")
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).not_to_be_visible()
|
||||
|
||||
def assertSidePanelIsHidden(self):
|
||||
expect(self.page.locator(".bookmarks-page .side-panel")).not_to_be_visible()
|
||||
expect(
|
||||
self.page.locator(".bookmarks-page [ld-tag-modal-trigger]")
|
||||
self.page.locator(".bookmarks-page [ld-filter-drawer-trigger]")
|
||||
).to_be_visible()
|
||||
|
||||
def test_side_panel_should_be_visible_by_default(self):
|
||||
|
@@ -4,7 +4,7 @@ from playwright.sync_api import sync_playwright, expect
|
||||
from bookmarks.e2e.helpers import LinkdingE2ETestCase
|
||||
|
||||
|
||||
class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
class FilterDrawerE2ETestCase(LinkdingE2ETestCase):
|
||||
def test_show_modal_close_modal(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="hiking")])
|
||||
@@ -12,31 +12,31 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as 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})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
# open drawer
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify modal is visible
|
||||
modal = page.locator(".modal")
|
||||
expect(modal).to_be_visible()
|
||||
expect(modal.locator("h2")).to_have_text("Tags")
|
||||
# verify drawer is visible
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
expect(drawer).to_be_visible()
|
||||
expect(drawer.locator("h2")).to_have_text("Filters")
|
||||
|
||||
# close with close button
|
||||
modal.locator("button.close").click()
|
||||
expect(modal).to_be_hidden()
|
||||
drawer.locator("button.close").click()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# close with backdrop
|
||||
backdrop = modal.locator(".modal-overlay")
|
||||
backdrop = drawer.locator(".modal-overlay")
|
||||
backdrop.click(position={"x": 0, "y": 0})
|
||||
expect(modal).to_be_hidden()
|
||||
expect(drawer).to_be_hidden()
|
||||
|
||||
def test_select_tag(self):
|
||||
self.setup_bookmark(tags=[self.setup_tag(name="cooking")])
|
||||
@@ -45,29 +45,29 @@ class TagCloudModalE2ETestCase(LinkdingE2ETestCase):
|
||||
with sync_playwright() as 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})
|
||||
|
||||
# open tag cloud modal
|
||||
modal_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Tags"
|
||||
drawer_trigger = page.locator(".content-area-header").get_by_role(
|
||||
"button", name="Filters"
|
||||
)
|
||||
modal_trigger.click()
|
||||
drawer_trigger.click()
|
||||
|
||||
# verify tags are displayed
|
||||
modal = page.locator(".modal")
|
||||
unselected_tags = modal.locator(".unselected-tags")
|
||||
drawer = page.locator(".modal.drawer.filter-drawer")
|
||||
unselected_tags = drawer.locator(".unselected-tags")
|
||||
expect(unselected_tags.get_by_text("cooking")).to_be_visible()
|
||||
expect(unselected_tags.get_by_text("hiking")).to_be_visible()
|
||||
|
||||
# select tag
|
||||
unselected_tags.get_by_text("cooking").click()
|
||||
|
||||
# open modal again
|
||||
modal_trigger.click()
|
||||
# open drawer again
|
||||
drawer_trigger.click()
|
||||
|
||||
# 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(unselected_tags.get_by_text("cooking")).not_to_be_visible()
|
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal file
97
bookmarks/frontend/behaviors/filter-drawer.js
Normal 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);
|
@@ -15,10 +15,7 @@ export class ModalBehavior extends Behavior {
|
||||
this.closeButton.addEventListener("click", this.onClose);
|
||||
document.addEventListener("keydown", this.onKeyDown);
|
||||
|
||||
this.setupInert();
|
||||
this.focusTrap = new FocusTrapController(
|
||||
element.querySelector(".modal-container"),
|
||||
);
|
||||
this.init();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
@@ -30,11 +27,20 @@ export class ModalBehavior extends Behavior {
|
||||
this.focusTrap.destroy();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupInert();
|
||||
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
|
||||
document.body.classList.add("scroll-lock");
|
||||
}
|
||||
|
||||
clearInert() {
|
||||
@@ -42,6 +48,8 @@ export class ModalBehavior extends Behavior {
|
||||
document
|
||||
.querySelectorAll("body > *")
|
||||
.forEach((el) => el.removeAttribute("inert"));
|
||||
// Remove scroll lock from the body
|
||||
document.body.classList.remove("scroll-lock");
|
||||
}
|
||||
|
||||
onKeyDown(event) {
|
||||
|
@@ -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);
|
@@ -3,13 +3,13 @@ import "./behaviors/bookmark-page";
|
||||
import "./behaviors/bulk-edit";
|
||||
import "./behaviors/clear-button";
|
||||
import "./behaviors/confirm-button";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/details-modal";
|
||||
import "./behaviors/dropdown";
|
||||
import "./behaviors/filter-drawer";
|
||||
import "./behaviors/form";
|
||||
import "./behaviors/global-shortcuts";
|
||||
import "./behaviors/search-autocomplete";
|
||||
import "./behaviors/tag-autocomplete";
|
||||
import "./behaviors/tag-modal";
|
||||
|
||||
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
|
||||
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
|
||||
|
@@ -15,7 +15,7 @@
|
||||
grid-gap: var(--unit-9);
|
||||
}
|
||||
|
||||
[ld-tag-modal-trigger] {
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-tag-modal-trigger] {
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,7 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
[ld-tag-modal-trigger] {
|
||||
[ld-filter-drawer-trigger] {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
@@ -2,7 +2,8 @@
|
||||
|
||||
/* Content area component */
|
||||
section.content-area {
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
font-size: var(--font-size-lg);
|
||||
}
|
||||
|
||||
@@ -14,7 +15,8 @@ section.content-area {
|
||||
padding-bottom: var(--unit-2);
|
||||
margin-bottom: var(--unit-4);
|
||||
|
||||
h2 {
|
||||
h2,
|
||||
h3 {
|
||||
flex: 0 0 auto;
|
||||
line-height: var(--unit-9);
|
||||
margin: 0;
|
||||
|
@@ -10,7 +10,6 @@ html {
|
||||
font-size: var(--html-font-size);
|
||||
line-height: var(--html-line-height);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
/* Reserve space for vert. scrollbar to avoid layout shifting when scrollbars are added */
|
||||
|
@@ -62,13 +62,14 @@
|
||||
gap: var(--unit-4);
|
||||
max-height: 75vh;
|
||||
max-width: var(--control-width-md);
|
||||
padding: var(--unit-6);
|
||||
width: 100%;
|
||||
|
||||
& .modal-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-6);
|
||||
padding-bottom: 0;
|
||||
color: var(--text-color);
|
||||
|
||||
& h2 {
|
||||
@@ -95,10 +96,53 @@
|
||||
|
||||
& .modal-body {
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
padding: 0 var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-body:not(:has(+ .modal-footer)) {
|
||||
margin-bottom: var(--unit-6);
|
||||
}
|
||||
|
||||
& .modal-footer {
|
||||
padding: var(--unit-6);
|
||||
padding-top: 0;
|
||||
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;
|
||||
}
|
||||
|
@@ -14,8 +14,7 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='archived' %}
|
||||
{% 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>
|
||||
|
||||
|
@@ -14,7 +14,7 @@
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search %}
|
||||
{% 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>
|
||||
|
||||
|
@@ -13,8 +13,7 @@
|
||||
<h2>Shared bookmarks</h2>
|
||||
<div class="header-controls">
|
||||
{% bookmark_search bookmark_list.search mode='shared' %}
|
||||
<button ld-tag-modal-trigger class="btn ml-2">Tags
|
||||
</button>
|
||||
<button ld-filter-drawer-trigger class="btn ml-2">Filters</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
@@ -127,11 +127,11 @@
|
||||
<div class="form-group">
|
||||
<label for="{{ form.collapse_side_panel.id_for_label }}" class="form-checkbox">
|
||||
{{ form.collapse_side_panel }}
|
||||
<i class="form-icon"></i> Collapse tags
|
||||
<i class="form-icon"></i> Collapse side panel
|
||||
</label>
|
||||
<div class="form-input-hint">
|
||||
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 class="form-group">
|
||||
|
Reference in New Issue
Block a user