From c5a300a4357d40d202f92dd08894ea520f2ade3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Sun, 2 Feb 2025 20:42:28 +0100 Subject: [PATCH] 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 --- bookmarks/e2e/e2e_test_collapse_side_panel.py | 4 +- ...oud_modal.py => e2e_test_filter_drawer.py} | 50 +++++----- bookmarks/frontend/behaviors/filter-drawer.js | 97 +++++++++++++++++++ bookmarks/frontend/behaviors/modal.js | 16 ++- bookmarks/frontend/behaviors/tag-modal.js | 76 --------------- bookmarks/frontend/index.js | 6 +- bookmarks/styles/bookmark-page.css | 6 +- bookmarks/styles/components.css | 6 +- bookmarks/styles/theme/base.css | 1 - bookmarks/styles/theme/modals.css | 48 ++++++++- bookmarks/templates/bookmarks/archive.html | 3 +- bookmarks/templates/bookmarks/index.html | 2 +- bookmarks/templates/bookmarks/shared.html | 3 +- bookmarks/templates/settings/general.html | 4 +- 14 files changed, 197 insertions(+), 125 deletions(-) rename bookmarks/e2e/{e2e_test_tag_cloud_modal.py => e2e_test_filter_drawer.py} (56%) create mode 100644 bookmarks/frontend/behaviors/filter-drawer.js delete mode 100644 bookmarks/frontend/behaviors/tag-modal.js diff --git a/bookmarks/e2e/e2e_test_collapse_side_panel.py b/bookmarks/e2e/e2e_test_collapse_side_panel.py index 407b4f7..a5b03d9 100644 --- a/bookmarks/e2e/e2e_test_collapse_side_panel.py +++ b/bookmarks/e2e/e2e_test_collapse_side_panel.py @@ -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): diff --git a/bookmarks/e2e/e2e_test_tag_cloud_modal.py b/bookmarks/e2e/e2e_test_filter_drawer.py similarity index 56% rename from bookmarks/e2e/e2e_test_tag_cloud_modal.py rename to bookmarks/e2e/e2e_test_filter_drawer.py index 7d60dcc..aabdd49 100644 --- a/bookmarks/e2e/e2e_test_tag_cloud_modal.py +++ b/bookmarks/e2e/e2e_test_filter_drawer.py @@ -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() diff --git a/bookmarks/frontend/behaviors/filter-drawer.js b/bookmarks/frontend/behaviors/filter-drawer.js new file mode 100644 index 0000000..8645b73 --- /dev/null +++ b/bookmarks/frontend/behaviors/filter-drawer.js @@ -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 = ` + + + + `; + 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); diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/behaviors/modal.js index 78d788c..bfad237 100644 --- a/bookmarks/frontend/behaviors/modal.js +++ b/bookmarks/frontend/behaviors/modal.js @@ -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) { diff --git a/bookmarks/frontend/behaviors/tag-modal.js b/bookmarks/frontend/behaviors/tag-modal.js deleted file mode 100644 index f515d5d..0000000 --- a/bookmarks/frontend/behaviors/tag-modal.js +++ /dev/null @@ -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 = ` - - - `; - 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); diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js index 789ff11..1f036bd 100644 --- a/bookmarks/frontend/index.js +++ b/bookmarks/frontend/index.js @@ -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"; diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css index 45f81e3..223ec49 100644 --- a/bookmarks/styles/bookmark-page.css +++ b/bookmarks/styles/bookmark-page.css @@ -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; } } diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css index d625196..b3c6f87 100644 --- a/bookmarks/styles/components.css +++ b/bookmarks/styles/components.css @@ -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; diff --git a/bookmarks/styles/theme/base.css b/bookmarks/styles/theme/base.css index 4bd64d8..4a269b8 100644 --- a/bookmarks/styles/theme/base.css +++ b/bookmarks/styles/theme/base.css @@ -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 */ diff --git a/bookmarks/styles/theme/modals.css b/bookmarks/styles/theme/modals.css index b5db0fb..102b663 100644 --- a/bookmarks/styles/theme/modals.css +++ b/bookmarks/styles/theme/modals.css @@ -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; +} diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html index 4ae8dee..d7a6a22 100644 --- a/bookmarks/templates/bookmarks/archive.html +++ b/bookmarks/templates/bookmarks/archive.html @@ -14,8 +14,7 @@
{% bookmark_search bookmark_list.search mode='archived' %} {% include 'bookmarks/bulk_edit/toggle.html' %} - +
diff --git a/bookmarks/templates/bookmarks/index.html b/bookmarks/templates/bookmarks/index.html index 7cb6c9a..e17f5e6 100644 --- a/bookmarks/templates/bookmarks/index.html +++ b/bookmarks/templates/bookmarks/index.html @@ -14,7 +14,7 @@
{% bookmark_search bookmark_list.search %} {% include 'bookmarks/bulk_edit/toggle.html' %} - +
diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html index a9195de..bc40d06 100644 --- a/bookmarks/templates/bookmarks/shared.html +++ b/bookmarks/templates/bookmarks/shared.html @@ -13,8 +13,7 @@

Shared bookmarks

{% bookmark_search bookmark_list.search mode='shared' %} - +
diff --git a/bookmarks/templates/settings/general.html b/bookmarks/templates/settings/general.html index 866d0fa..427ca33 100644 --- a/bookmarks/templates/settings/general.html +++ b/bookmarks/templates/settings/general.html @@ -127,11 +127,11 @@
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.