From 4fed5de7b33dad1657377878907611b9d4aed903 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sascha=20I=C3=9Fbr=C3=BCcker?= Date: Wed, 31 Dec 2025 14:37:09 +0100 Subject: [PATCH] Convert behaviors to web components --- bookmarks/frontend/behaviors/bookmark-page.js | 37 -- bookmarks/frontend/behaviors/bulk-edit.js | 125 ------ bookmarks/frontend/behaviors/clear-button.js | 42 -- .../frontend/behaviors/confirm-button.js | 113 ------ bookmarks/frontend/behaviors/details-modal.js | 17 - bookmarks/frontend/behaviors/dropdown.js | 73 ---- bookmarks/frontend/behaviors/filter-drawer.js | 97 ----- bookmarks/frontend/behaviors/form.js | 109 ------ .../frontend/behaviors/global-shortcuts.js | 80 ---- bookmarks/frontend/behaviors/index.js | 119 ------ .../frontend/behaviors/search-autocomplete.js | 35 -- .../frontend/behaviors/tag-autocomplete.js | 31 -- .../frontend/components/bookmark-page.js | 142 +++++++ bookmarks/frontend/components/clear-button.js | 39 ++ .../frontend/components/confirm-dropdown.js | 103 +++++ .../frontend/components/details-modal.js | 16 + bookmarks/frontend/components/dropdown.js | 76 ++++ .../frontend/components/filter-drawer.js | 109 ++++++ bookmarks/frontend/components/form.js | 73 ++++ .../{behaviors => components}/modal.js | 55 ++- ...Autocomplete.js => search-autocomplete.js} | 60 +-- ...TagAutocomplete.js => tag-autocomplete.js} | 45 +-- .../frontend/components/upload-button.js | 36 ++ bookmarks/frontend/index.js | 26 +- bookmarks/frontend/shortcuts.js | 62 +++ bookmarks/frontend/utils/element.js | 44 +++ .../focus-utils.js => utils/focus.js} | 0 .../frontend/{util.js => utils/input.js} | 7 - .../position-controller.js | 0 .../search-history.js} | 0 .../frontend/{cache.js => utils/tag-cache.js} | 6 +- bookmarks/styles/bookmark-form.css | 2 +- bookmarks/styles/bookmark-page.css | 359 +++++++++--------- bookmarks/styles/theme/autocomplete.css | 2 +- bookmarks/templates/bookmarks/archive.html | 10 +- .../templates/bookmarks/bookmark_list.html | 20 +- .../templates/bookmarks/bulk_edit/bar.html | 6 +- .../templates/bookmarks/bundle_section.html | 4 +- .../templates/bookmarks/details/assets.html | 12 +- .../templates/bookmarks/details/form.html | 198 +++++----- .../templates/bookmarks/details/modal.html | 10 +- bookmarks/templates/bookmarks/edit.html | 10 +- bookmarks/templates/bookmarks/form.html | 278 +++++++------- bookmarks/templates/bookmarks/index.html | 8 +- bookmarks/templates/bookmarks/layout.html | 2 +- bookmarks/templates/bookmarks/nav_menu.html | 12 +- bookmarks/templates/bookmarks/new.html | 8 +- bookmarks/templates/bookmarks/search.html | 23 +- bookmarks/templates/bookmarks/shared.html | 8 +- .../templates/bookmarks/tag_section.html | 4 +- .../templates/bookmarks/user_select.html | 26 +- bookmarks/templates/bundles/form.html | 76 ++-- bookmarks/templates/bundles/index.html | 2 +- .../settings/create_api_token_modal.html | 4 +- .../templates/settings/integrations.html | 2 +- bookmarks/templates/tags/index.html | 56 +-- bookmarks/templates/tags/merge.html | 12 +- bookmarks/tests/helpers.py | 6 +- ...test_bookmark_archived_view_performance.py | 4 +- .../tests/test_bookmark_details_modal.py | 6 +- bookmarks/tests/test_bookmark_edit_view.py | 5 +- .../test_bookmark_index_view_performance.py | 4 +- bookmarks/tests/test_bookmark_new_view.py | 5 +- bookmarks/tests/test_bookmark_search_tag.py | 4 +- bookmarks/tests/test_bookmark_shared_view.py | 2 +- .../test_bookmark_shared_view_performance.py | 4 +- .../tests/test_bookmarks_list_template.py | 16 +- bookmarks/tests/test_bundles_edit_view.py | 37 +- bookmarks/tests/test_bundles_index_view.py | 2 +- bookmarks/tests/test_bundles_new_view.py | 16 +- bookmarks/tests/test_bundles_preview_view.py | 2 +- bookmarks/tests/test_tags_index_view.py | 4 +- bookmarks/tests/test_tags_merge_view.py | 5 + .../e2e_test_bookmark_details_modal.py | 1 - .../e2e_test_bookmark_page_bulk_edit.py | 26 +- .../e2e_test_bookmark_page_partial_updates.py | 6 +- .../tests_e2e/e2e_test_collapse_side_panel.py | 4 +- bookmarks/tests_e2e/e2e_test_dropdown.py | 128 +++++++ bookmarks/tests_e2e/e2e_test_filter_drawer.py | 4 +- .../tests_e2e/e2e_test_new_bookmark_form.py | 4 +- bookmarks/tests_e2e/helpers.py | 10 +- 81 files changed, 1598 insertions(+), 1638 deletions(-) delete mode 100644 bookmarks/frontend/behaviors/bookmark-page.js delete mode 100644 bookmarks/frontend/behaviors/bulk-edit.js delete mode 100644 bookmarks/frontend/behaviors/clear-button.js delete mode 100644 bookmarks/frontend/behaviors/confirm-button.js delete mode 100644 bookmarks/frontend/behaviors/details-modal.js delete mode 100644 bookmarks/frontend/behaviors/dropdown.js delete mode 100644 bookmarks/frontend/behaviors/filter-drawer.js delete mode 100644 bookmarks/frontend/behaviors/form.js delete mode 100644 bookmarks/frontend/behaviors/global-shortcuts.js delete mode 100644 bookmarks/frontend/behaviors/index.js delete mode 100644 bookmarks/frontend/behaviors/search-autocomplete.js delete mode 100644 bookmarks/frontend/behaviors/tag-autocomplete.js create mode 100644 bookmarks/frontend/components/bookmark-page.js create mode 100644 bookmarks/frontend/components/clear-button.js create mode 100644 bookmarks/frontend/components/confirm-dropdown.js create mode 100644 bookmarks/frontend/components/details-modal.js create mode 100644 bookmarks/frontend/components/dropdown.js create mode 100644 bookmarks/frontend/components/filter-drawer.js create mode 100644 bookmarks/frontend/components/form.js rename bookmarks/frontend/{behaviors => components}/modal.js (50%) rename bookmarks/frontend/components/{SearchAutocomplete.js => search-autocomplete.js} (84%) rename bookmarks/frontend/components/{TagAutocomplete.js => tag-autocomplete.js} (80%) create mode 100644 bookmarks/frontend/components/upload-button.js create mode 100644 bookmarks/frontend/shortcuts.js create mode 100644 bookmarks/frontend/utils/element.js rename bookmarks/frontend/{behaviors/focus-utils.js => utils/focus.js} (100%) rename bookmarks/frontend/{util.js => utils/input.js} (86%) rename bookmarks/frontend/{behaviors => utils}/position-controller.js (100%) rename bookmarks/frontend/{components/SearchHistory.js => utils/search-history.js} (100%) rename bookmarks/frontend/{cache.js => utils/tag-cache.js} (88%) create mode 100644 bookmarks/tests_e2e/e2e_test_dropdown.py diff --git a/bookmarks/frontend/behaviors/bookmark-page.js b/bookmarks/frontend/behaviors/bookmark-page.js deleted file mode 100644 index 681f5f6..0000000 --- a/bookmarks/frontend/behaviors/bookmark-page.js +++ /dev/null @@ -1,37 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class BookmarkItem extends Behavior { - constructor(element) { - super(element); - - // Toggle notes - this.onToggleNotes = this.onToggleNotes.bind(this); - this.notesToggle = element.querySelector(".toggle-notes"); - if (this.notesToggle) { - this.notesToggle.addEventListener("click", this.onToggleNotes); - } - - // Add tooltip to title if it is truncated - const titleAnchor = element.querySelector(".title > a"); - const titleSpan = titleAnchor.querySelector("span"); - requestAnimationFrame(() => { - if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { - titleAnchor.dataset.tooltip = titleSpan.textContent; - } - }); - } - - destroy() { - if (this.notesToggle) { - this.notesToggle.removeEventListener("click", this.onToggleNotes); - } - } - - onToggleNotes(event) { - event.preventDefault(); - event.stopPropagation(); - this.element.classList.toggle("show-notes"); - } -} - -registerBehavior("ld-bookmark-item", BookmarkItem); diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js deleted file mode 100644 index 9d3bb15..0000000 --- a/bookmarks/frontend/behaviors/bulk-edit.js +++ /dev/null @@ -1,125 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class BulkEdit extends Behavior { - constructor(element) { - super(element); - - this.active = element.classList.contains("active"); - - this.init = this.init.bind(this); - this.onToggleActive = this.onToggleActive.bind(this); - this.onToggleAll = this.onToggleAll.bind(this); - this.onToggleBookmark = this.onToggleBookmark.bind(this); - this.onActionSelected = this.onActionSelected.bind(this); - - this.init(); - // Reset when bookmarks are updated - document.addEventListener("bookmark-list-updated", this.init); - } - - destroy() { - this.removeListeners(); - document.removeEventListener("bookmark-list-updated", this.init); - } - - init() { - // Update elements - this.activeToggle = this.element.querySelector(".bulk-edit-active-toggle"); - this.actionSelect = this.element.querySelector( - "select[name='bulk_action']", - ); - this.tagAutoComplete = this.element.querySelector(".tag-autocomplete"); - this.selectAcross = this.element.querySelector("label.select-across"); - this.allCheckbox = this.element.querySelector( - ".bulk-edit-checkbox.all input", - ); - this.bookmarkCheckboxes = Array.from( - this.element.querySelectorAll(".bulk-edit-checkbox:not(.all) input"), - ); - - // Add listeners, ensure there are no dupes by possibly removing existing listeners - this.removeListeners(); - this.addListeners(); - - // Reset checkbox states - this.reset(); - - // Update total number of bookmarks - const totalHolder = this.element.querySelector("[data-bookmarks-total]"); - const total = totalHolder?.dataset.bookmarksTotal || 0; - const totalSpan = this.selectAcross.querySelector("span.total"); - totalSpan.textContent = total; - } - - addListeners() { - this.activeToggle.addEventListener("click", this.onToggleActive); - this.actionSelect.addEventListener("change", this.onActionSelected); - this.allCheckbox.addEventListener("change", this.onToggleAll); - this.bookmarkCheckboxes.forEach((checkbox) => { - checkbox.addEventListener("change", this.onToggleBookmark); - }); - } - - removeListeners() { - this.activeToggle.removeEventListener("click", this.onToggleActive); - this.actionSelect.removeEventListener("change", this.onActionSelected); - this.allCheckbox.removeEventListener("change", this.onToggleAll); - this.bookmarkCheckboxes.forEach((checkbox) => { - checkbox.removeEventListener("change", this.onToggleBookmark); - }); - } - - onToggleActive() { - this.active = !this.active; - if (this.active) { - this.element.classList.add("active"); - } else { - this.element.classList.remove("active"); - } - } - - onToggleBookmark() { - const allChecked = this.bookmarkCheckboxes.every((checkbox) => { - return checkbox.checked; - }); - this.allCheckbox.checked = allChecked; - this.updateSelectAcross(allChecked); - } - - onToggleAll() { - const allChecked = this.allCheckbox.checked; - this.bookmarkCheckboxes.forEach((checkbox) => { - checkbox.checked = allChecked; - }); - this.updateSelectAcross(allChecked); - } - - onActionSelected() { - const action = this.actionSelect.value; - - if (action === "bulk_tag" || action === "bulk_untag") { - this.tagAutoComplete.classList.remove("d-none"); - } else { - this.tagAutoComplete.classList.add("d-none"); - } - } - - updateSelectAcross(allChecked) { - if (allChecked) { - this.selectAcross.classList.remove("d-none"); - } else { - this.selectAcross.classList.add("d-none"); - this.selectAcross.querySelector("input").checked = false; - } - } - - reset() { - this.allCheckbox.checked = false; - this.bookmarkCheckboxes.forEach((checkbox) => { - checkbox.checked = false; - }); - this.updateSelectAcross(false); - } -} - -registerBehavior("ld-bulk-edit", BulkEdit); diff --git a/bookmarks/frontend/behaviors/clear-button.js b/bookmarks/frontend/behaviors/clear-button.js deleted file mode 100644 index 3f0b476..0000000 --- a/bookmarks/frontend/behaviors/clear-button.js +++ /dev/null @@ -1,42 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class ClearButtonBehavior extends Behavior { - constructor(element) { - super(element); - - this.field = document.getElementById(element.dataset.for); - if (!this.field) { - console.error(`Field with ID ${element.dataset.for} not found`); - return; - } - - this.update = this.update.bind(this); - this.clear = this.clear.bind(this); - - this.element.addEventListener("click", this.clear); - this.field.addEventListener("input", this.update); - this.field.addEventListener("value-changed", this.update); - this.update(); - } - - destroy() { - if (!this.field) { - return; - } - this.element.removeEventListener("click", this.clear); - this.field.removeEventListener("input", this.update); - this.field.removeEventListener("value-changed", this.update); - } - - update() { - this.element.style.display = this.field.value ? "inline-flex" : "none"; - } - - clear() { - this.field.value = ""; - this.field.focus(); - this.update(); - } -} - -registerBehavior("ld-clear-button", ClearButtonBehavior); diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js deleted file mode 100644 index a457896..0000000 --- a/bookmarks/frontend/behaviors/confirm-button.js +++ /dev/null @@ -1,113 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; -import { FocusTrapController, isKeyboardActive } from "./focus-utils"; -import { PositionController } from "./position-controller"; - -let confirmId = 0; - -function nextConfirmId() { - return `confirm-${confirmId++}`; -} - -class ConfirmButtonBehavior extends Behavior { - constructor(element) { - super(element); - - this.onClick = this.onClick.bind(this); - this.element.addEventListener("click", this.onClick); - } - - destroy() { - if (this.opened) { - this.close(); - } - this.element.removeEventListener("click", this.onClick); - } - - onClick(event) { - event.preventDefault(); - - if (this.opened) { - this.close(); - } else { - this.open(); - } - } - - open() { - const dropdown = document.createElement("div"); - dropdown.className = "dropdown confirm-dropdown active"; - - const confirmId = nextConfirmId(); - const questionId = `${confirmId}-question`; - - 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.className = "btn"; - cancelButton.tabIndex = 0; - cancelButton.addEventListener("click", () => this.close()); - - const confirmButton = document.createElement("button"); - confirmButton.textContent = "Confirm"; - confirmButton.type = "submit"; - confirmButton.name = this.element.name; - confirmButton.value = this.element.value; - confirmButton.className = "btn btn-error"; - confirmButton.addEventListener("click", () => this.confirm()); - - const arrow = document.createElement("div"); - arrow.className = "menu-arrow"; - - menu.append(question, cancelButton, confirmButton, arrow); - dropdown.append(menu); - document.body.append(dropdown); - - this.positionController = new PositionController({ - anchor: this.element, - overlay: menu, - arrow: arrow, - offset: 12, - }); - this.positionController.enable(); - this.focusTrap = new FocusTrapController(menu); - this.dropdown = dropdown; - this.opened = true; - } - - onMenuKeyDown(event) { - if (event.key === "Escape") { - event.preventDefault(); - event.stopPropagation(); - this.close(); - } - } - - confirm() { - this.element.closest("form").requestSubmit(this.element); - this.close(); - } - - close() { - if (!this.opened) return; - this.positionController.disable(); - this.focusTrap.destroy(); - this.dropdown.remove(); - this.element.focus({ focusVisible: isKeyboardActive() }); - this.opened = false; - } -} - -registerBehavior("ld-confirm-button", ConfirmButtonBehavior); diff --git a/bookmarks/frontend/behaviors/details-modal.js b/bookmarks/frontend/behaviors/details-modal.js deleted file mode 100644 index 97a1397..0000000 --- a/bookmarks/frontend/behaviors/details-modal.js +++ /dev/null @@ -1,17 +0,0 @@ -import { registerBehavior } from "./index"; -import { isKeyboardActive, setAfterPageLoadFocusTarget } from "./focus-utils"; -import { ModalBehavior } from "./modal"; - -class DetailsModalBehavior extends ModalBehavior { - doClose() { - super.doClose(); - - // Try restore focus to view details to view details link of respective bookmark - const bookmarkId = this.element.dataset.bookmarkId; - setAfterPageLoadFocusTarget( - `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, - ); - } -} - -registerBehavior("ld-details-modal", DetailsModalBehavior); diff --git a/bookmarks/frontend/behaviors/dropdown.js b/bookmarks/frontend/behaviors/dropdown.js deleted file mode 100644 index bc4c580..0000000 --- a/bookmarks/frontend/behaviors/dropdown.js +++ /dev/null @@ -1,73 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class DropdownBehavior extends Behavior { - constructor(element) { - super(element); - this.opened = false; - this.onClick = this.onClick.bind(this); - this.onOutsideClick = this.onOutsideClick.bind(this); - this.onEscape = this.onEscape.bind(this); - this.onFocusOut = this.onFocusOut.bind(this); - - // Prevent opening the dropdown automatically on focus, so that it only - // opens on click then JS is enabled - this.element.style.setProperty("--dropdown-focus-display", "none"); - this.element.addEventListener("keydown", this.onEscape); - this.element.addEventListener("focusout", this.onFocusOut); - - this.toggle = element.querySelector(".dropdown-toggle"); - this.toggle.setAttribute("aria-expanded", "false"); - this.toggle.addEventListener("click", this.onClick); - } - - destroy() { - this.close(); - this.toggle.removeEventListener("click", this.onClick); - this.element.removeEventListener("keydown", this.onEscape); - this.element.removeEventListener("focusout", this.onFocusOut); - } - - open() { - this.opened = true; - this.element.classList.add("active"); - this.toggle.setAttribute("aria-expanded", "true"); - document.addEventListener("click", this.onOutsideClick); - } - - close() { - this.opened = false; - this.element.classList.remove("active"); - this.toggle.setAttribute("aria-expanded", "false"); - document.removeEventListener("click", this.onOutsideClick); - } - - onClick() { - if (this.opened) { - this.close(); - } else { - this.open(); - } - } - - onOutsideClick(event) { - if (!this.element.contains(event.target)) { - this.close(); - } - } - - onEscape(event) { - if (event.key === "Escape" && this.opened) { - event.preventDefault(); - this.close(); - this.toggle.focus(); - } - } - - onFocusOut(event) { - if (!this.element.contains(event.relatedTarget)) { - this.close(); - } - } -} - -registerBehavior("ld-dropdown", DropdownBehavior); diff --git a/bookmarks/frontend/behaviors/filter-drawer.js b/bookmarks/frontend/behaviors/filter-drawer.js deleted file mode 100644 index 62c4413..0000000 --- a/bookmarks/frontend/behaviors/filter-drawer.js +++ /dev/null @@ -1,97 +0,0 @@ -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(".side-panel"); - content.append(...sidePanel.children); - this.mapHeading(content, "h2", "h3"); - } - - teleportBack() { - const sidePanel = document.querySelector(".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/form.js b/bookmarks/frontend/behaviors/form.js deleted file mode 100644 index 98cbc66..0000000 --- a/bookmarks/frontend/behaviors/form.js +++ /dev/null @@ -1,109 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class FormSubmit extends Behavior { - constructor(element) { - super(element); - - this.onKeyDown = this.onKeyDown.bind(this); - this.element.addEventListener("keydown", this.onKeyDown); - } - - destroy() { - this.element.removeEventListener("keydown", this.onKeyDown); - } - - onKeyDown(event) { - // Check for Ctrl/Cmd + Enter combination - if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { - event.preventDefault(); - event.stopPropagation(); - this.element.requestSubmit(); - } - } -} - -class AutoSubmitBehavior extends Behavior { - constructor(element) { - super(element); - - this.submit = this.submit.bind(this); - element.addEventListener("change", this.submit); - } - - destroy() { - this.element.removeEventListener("change", this.submit); - } - - submit() { - this.element.closest("form").requestSubmit(); - } -} - -// Resets form controls to their initial values before Turbo caches the DOM. -// Useful for filter forms where navigating back would otherwise still show -// values from after the form submission, which means the filters would be out -// of sync with the URL. -class FormResetBehavior extends Behavior { - constructor(element) { - super(element); - - this.controls = this.element.querySelectorAll("input, select"); - this.controls.forEach((control) => { - if (control.type === "checkbox" || control.type === "radio") { - control.__initialValue = control.checked; - } else { - control.__initialValue = control.value; - } - }); - } - - destroy() { - this.controls.forEach((control) => { - if (control.type === "checkbox" || control.type === "radio") { - control.checked = control.__initialValue; - } else { - control.value = control.__initialValue; - } - delete control.__initialValue; - }); - } -} - -class UploadButton extends Behavior { - constructor(element) { - super(element); - this.fileInput = element.nextElementSibling; - - this.onClick = this.onClick.bind(this); - this.onChange = this.onChange.bind(this); - - element.addEventListener("click", this.onClick); - this.fileInput.addEventListener("change", this.onChange); - } - - destroy() { - this.element.removeEventListener("click", this.onClick); - this.fileInput.removeEventListener("change", this.onChange); - } - - onClick(event) { - event.preventDefault(); - this.fileInput.click(); - } - - onChange() { - // Check if the file input has a file selected - if (!this.fileInput.files.length) { - return; - } - const form = this.fileInput.closest("form"); - form.requestSubmit(this.element); - // remove selected file so it doesn't get submitted again - this.fileInput.value = ""; - } -} - -registerBehavior("ld-form-submit", FormSubmit); -registerBehavior("ld-auto-submit", AutoSubmitBehavior); -registerBehavior("ld-form-reset", FormResetBehavior); -registerBehavior("ld-upload-button", UploadButton); diff --git a/bookmarks/frontend/behaviors/global-shortcuts.js b/bookmarks/frontend/behaviors/global-shortcuts.js deleted file mode 100644 index 68e16f7..0000000 --- a/bookmarks/frontend/behaviors/global-shortcuts.js +++ /dev/null @@ -1,80 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; - -class GlobalShortcuts extends Behavior { - constructor(element) { - super(element); - - this.onKeyDown = this.onKeyDown.bind(this); - document.addEventListener("keydown", this.onKeyDown); - } - - destroy() { - 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; - } - - // Handle shortcuts for navigating bookmarks with arrow keys - const isArrowUp = event.key === "ArrowUp"; - const isArrowDown = event.key === "ArrowDown"; - if (isArrowUp || isArrowDown) { - event.preventDefault(); - - // Detect current bookmark list item - const path = event.composedPath(); - const currentItem = path.find( - (item) => item.hasAttribute && item.hasAttribute("ld-bookmark-item"), - ); - - // Find next item - let nextItem; - if (currentItem) { - nextItem = isArrowUp - ? currentItem.previousElementSibling - : currentItem.nextElementSibling; - } else { - // Select first item - nextItem = document.querySelector("[ld-bookmark-item]"); - } - // Focus first link - if (nextItem) { - nextItem.querySelector("a").focus(); - } - } - - // Handle shortcut for toggling all notes - if (event.key === "e") { - const list = document.querySelector(".bookmark-list"); - if (list) { - list.classList.toggle("show-notes"); - } - } - - // Handle shortcut for focusing search input - if (event.key === "s") { - const searchInput = document.querySelector('input[type="search"]'); - - if (searchInput) { - searchInput.focus(); - event.preventDefault(); - } - } - - // Handle shortcut for adding new bookmark - if (event.key === "n") { - window.location.assign("/bookmarks/new"); - } - } -} - -registerBehavior("ld-global-shortcuts", GlobalShortcuts); diff --git a/bookmarks/frontend/behaviors/index.js b/bookmarks/frontend/behaviors/index.js deleted file mode 100644 index ab7e13c..0000000 --- a/bookmarks/frontend/behaviors/index.js +++ /dev/null @@ -1,119 +0,0 @@ -const behaviorRegistry = {}; -const debug = false; - -const mutationObserver = new MutationObserver((mutations) => { - mutations.forEach((mutation) => { - mutation.removedNodes.forEach((node) => { - if (node instanceof HTMLElement && !node.isConnected) { - destroyBehaviors(node); - } - }); - mutation.addedNodes.forEach((node) => { - if (node instanceof HTMLElement && node.isConnected) { - applyBehaviors(node); - } - }); - }); -}); - -// Update behaviors on Turbo events -// - turbo:load: initial page load, only listen once, afterward can rely on turbo:render -// - turbo:render: after page navigation, including back/forward, and failed form submissions -// - turbo:before-cache: before page navigation, reset DOM before caching -document.addEventListener( - "turbo:load", - () => { - mutationObserver.observe(document.body, { - childList: true, - subtree: true, - }); - - applyBehaviors(document.body); - }, - { once: true }, -); - -document.addEventListener("turbo:render", () => { - mutationObserver.observe(document.body, { - childList: true, - subtree: true, - }); - - applyBehaviors(document.body); -}); - -document.addEventListener("turbo:before-cache", () => { - destroyBehaviors(document.body); -}); - -export class Behavior { - constructor(element) { - this.element = element; - } - - destroy() {} -} - -export function registerBehavior(name, behavior) { - behaviorRegistry[name] = behavior; -} - -export function applyBehaviors(container, behaviorNames = null) { - if (!behaviorNames) { - behaviorNames = Object.keys(behaviorRegistry); - } - - behaviorNames.forEach((behaviorName) => { - const behavior = behaviorRegistry[behaviorName]; - const elements = Array.from( - container.querySelectorAll(`[${behaviorName}]`), - ); - - // Include the container element if it has the behavior - if (container.hasAttribute && container.hasAttribute(behaviorName)) { - elements.push(container); - } - - elements.forEach((element) => { - element.__behaviors = element.__behaviors || []; - const hasBehavior = element.__behaviors.some( - (b) => b instanceof behavior, - ); - - if (hasBehavior) { - return; - } - - const behaviorInstance = new behavior(element); - element.__behaviors.push(behaviorInstance); - if (debug) { - console.log( - `[Behavior] ${behaviorInstance.constructor.name} initialized`, - ); - } - }); - }); -} - -export function destroyBehaviors(element) { - const behaviorNames = Object.keys(behaviorRegistry); - - behaviorNames.forEach((behaviorName) => { - const elements = Array.from(element.querySelectorAll(`[${behaviorName}]`)); - elements.push(element); - - elements.forEach((element) => { - if (!element.__behaviors) { - return; - } - - element.__behaviors.forEach((behavior) => { - behavior.destroy(); - if (debug) { - console.log(`[Behavior] ${behavior.constructor.name} destroyed`); - } - }); - delete element.__behaviors; - }); - }); -} diff --git a/bookmarks/frontend/behaviors/search-autocomplete.js b/bookmarks/frontend/behaviors/search-autocomplete.js deleted file mode 100644 index 3259744..0000000 --- a/bookmarks/frontend/behaviors/search-autocomplete.js +++ /dev/null @@ -1,35 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; -import "../components/SearchAutocomplete.js"; - -class SearchAutocomplete extends Behavior { - constructor(element) { - super(element); - const input = element.querySelector("input"); - if (!input) { - console.warn("SearchAutocomplete: input element not found"); - return; - } - - const autocomplete = document.createElement("ld-search-autocomplete"); - autocomplete.name = "q"; - autocomplete.placeholder = input.getAttribute("placeholder") || ""; - autocomplete.value = input.value; - autocomplete.linkTarget = input.dataset.linkTarget || "_blank"; - autocomplete.mode = input.dataset.mode || ""; - autocomplete.search = { - user: input.dataset.user, - shared: input.dataset.shared, - unread: input.dataset.unread, - }; - - this.input = input; - this.autocomplete = autocomplete; - input.replaceWith(this.autocomplete); - } - - destroy() { - this.autocomplete.replaceWith(this.input); - } -} - -registerBehavior("ld-search-autocomplete", SearchAutocomplete); diff --git a/bookmarks/frontend/behaviors/tag-autocomplete.js b/bookmarks/frontend/behaviors/tag-autocomplete.js deleted file mode 100644 index c1f6fe3..0000000 --- a/bookmarks/frontend/behaviors/tag-autocomplete.js +++ /dev/null @@ -1,31 +0,0 @@ -import { Behavior, registerBehavior } from "./index"; -import "../components/TagAutocomplete.js"; - -class TagAutocomplete extends Behavior { - constructor(element) { - super(element); - const input = element.querySelector("input"); - if (!input) { - console.warn("TagAutocomplete: input element not found"); - return; - } - - const autocomplete = document.createElement("ld-tag-autocomplete"); - autocomplete.id = input.id; - autocomplete.name = input.name; - autocomplete.value = input.value; - autocomplete.placeholder = input.getAttribute("placeholder") || ""; - autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || ""; - autocomplete.variant = input.getAttribute("variant") || "default"; - - this.input = input; - this.autocomplete = autocomplete; - input.replaceWith(this.autocomplete); - } - - destroy() { - this.autocomplete.replaceWith(this.input); - } -} - -registerBehavior("ld-tag-autocomplete", TagAutocomplete); diff --git a/bookmarks/frontend/components/bookmark-page.js b/bookmarks/frontend/components/bookmark-page.js new file mode 100644 index 0000000..d2c3c60 --- /dev/null +++ b/bookmarks/frontend/components/bookmark-page.js @@ -0,0 +1,142 @@ +class BookmarkPage extends HTMLElement { + connectedCallback() { + this.update = this.update.bind(this); + this.onToggleNotes = this.onToggleNotes.bind(this); + this.onToggleBulkEdit = this.onToggleBulkEdit.bind(this); + this.onBulkActionChange = this.onBulkActionChange.bind(this); + this.onToggleAll = this.onToggleAll.bind(this); + this.onToggleBookmark = this.onToggleBookmark.bind(this); + + this.oldItems = []; + this.update(); + document.addEventListener("bookmark-list-updated", this.update); + } + + disconnectedCallback() { + document.removeEventListener("bookmark-list-updated", this.update); + } + + update() { + requestAnimationFrame(() => { + const items = this.querySelectorAll("ul.bookmark-list > li"); + this.updateTooltips(items); + this.updateNotesToggles(items, this.oldItems); + this.updateBulkEdit(items, this.oldItems); + this.oldItems = items; + }); + } + + updateTooltips(items) { + // Add tooltip to title if it is truncated + items.forEach((item) => { + const titleAnchor = item.querySelector(".title > a"); + const titleSpan = titleAnchor.querySelector("span"); + if (titleSpan.offsetWidth > titleAnchor.offsetWidth) { + titleAnchor.dataset.tooltip = titleSpan.textContent; + } else { + delete titleAnchor.dataset.tooltip; + } + }); + } + + updateNotesToggles(items, oldItems) { + oldItems.forEach((oldItem) => { + const oldToggle = oldItem.querySelector(".toggle-notes"); + if (oldToggle) { + oldToggle.removeEventListener("click", this.onToggleNotes); + } + }); + + items.forEach((item) => { + const notesToggle = item.querySelector(".toggle-notes"); + if (notesToggle) { + notesToggle.addEventListener("click", this.onToggleNotes); + } + }); + } + + onToggleNotes(event) { + event.preventDefault(); + event.stopPropagation(); + event.target.closest("li").classList.toggle("show-notes"); + } + + updateBulkEdit() { + if (this.hasAttribute("no-bulk-edit")) { + return; + } + + // Remove existing listeners + this.activeToggle?.removeEventListener("click", this.onToggleBulkEdit); + this.actionSelect?.removeEventListener("change", this.onBulkActionChange); + this.allCheckbox?.removeEventListener("change", this.onToggleAll); + this.bookmarkCheckboxes?.forEach((checkbox) => { + checkbox.removeEventListener("change", this.onToggleBookmark); + }); + + // Re-query elements + this.activeToggle = this.querySelector(".bulk-edit-active-toggle"); + this.actionSelect = this.querySelector("select[name='bulk_action']"); + this.allCheckbox = this.querySelector(".bulk-edit-checkbox.all input"); + this.bookmarkCheckboxes = Array.from( + this.querySelectorAll(".bulk-edit-checkbox:not(.all) input"), + ); + this.selectAcross = this.querySelector("label.select-across"); + + // Add listeners + this.activeToggle.addEventListener("click", this.onToggleBulkEdit); + this.actionSelect.addEventListener("change", this.onBulkActionChange); + this.allCheckbox.addEventListener("change", this.onToggleAll); + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.addEventListener("change", this.onToggleBookmark); + }); + + // Reset checkbox states + this.allCheckbox.checked = false; + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.checked = false; + }); + this.updateSelectAcross(false); + + // Update total number of bookmarks + const totalHolder = this.querySelector("[data-bookmarks-total]"); + const total = totalHolder?.dataset.bookmarksTotal || 0; + const totalSpan = this.selectAcross.querySelector("span.total"); + totalSpan.textContent = total; + } + + onToggleBulkEdit() { + this.classList.toggle("active"); + } + + onBulkActionChange() { + this.dataset.bulkAction = this.actionSelect.value; + } + + onToggleAll() { + const allChecked = this.allCheckbox.checked; + this.bookmarkCheckboxes.forEach((checkbox) => { + checkbox.checked = allChecked; + }); + this.updateSelectAcross(allChecked); + } + + onToggleBookmark() { + const allChecked = this.bookmarkCheckboxes.every((checkbox) => { + return checkbox.checked; + }); + this.allCheckbox.checked = allChecked; + this.updateSelectAcross(allChecked); + } + + updateSelectAcross(allChecked) { + if (allChecked) { + this.selectAcross.classList.remove("d-none"); + } else { + this.selectAcross.classList.add("d-none"); + this.selectAcross.querySelector("input").checked = false; + } + } +} + +customElements.define("ld-bookmark-page", BookmarkPage); diff --git a/bookmarks/frontend/components/clear-button.js b/bookmarks/frontend/components/clear-button.js new file mode 100644 index 0000000..34f8fe3 --- /dev/null +++ b/bookmarks/frontend/components/clear-button.js @@ -0,0 +1,39 @@ +class ClearButton extends HTMLElement { + connectedCallback() { + requestAnimationFrame(() => { + this.field = document.getElementById(this.dataset.for); + if (!this.field) { + console.error(`Field with ID ${this.dataset.for} not found`); + return; + } + this.update = this.update.bind(this); + this.clear = this.clear.bind(this); + + this.addEventListener("click", this.clear); + this.field.addEventListener("input", this.update); + this.field.addEventListener("value-changed", this.update); + this.update(); + }); + } + + disconnectedCallback() { + if (!this.field) { + return; + } + this.removeEventListener("click", this.clear); + this.field.removeEventListener("input", this.update); + this.field.removeEventListener("value-changed", this.update); + } + + update() { + this.style.display = this.field.value ? "inline" : "none"; + } + + clear() { + this.field.value = ""; + this.field.focus(); + this.update(); + } +} + +customElements.define("ld-clear-button", ClearButton); diff --git a/bookmarks/frontend/components/confirm-dropdown.js b/bookmarks/frontend/components/confirm-dropdown.js new file mode 100644 index 0000000..2e091f7 --- /dev/null +++ b/bookmarks/frontend/components/confirm-dropdown.js @@ -0,0 +1,103 @@ +import { html, LitElement } from "lit"; +import { FocusTrapController, isKeyboardActive } from "../utils/focus.js"; +import { PositionController } from "../utils/position-controller.js"; + +let confirmId = 0; + +function nextConfirmId() { + return `confirm-${confirmId++}`; +} + +function removeAll() { + document + .querySelectorAll("ld-confirm-dropdown") + .forEach((dropdown) => dropdown.close()); +} + +// Create a confirm dropdown whenever a button with the data-confirm attribute is clicked +document.addEventListener("click", (event) => { + // Check if the clicked element is a button with data-confirm + const button = event.target.closest("button[data-confirm]"); + if (!button) return; + + // Remove any existing confirm dropdowns + removeAll(); + + // Show confirmation dropdown + event.preventDefault(); + + const dropdown = document.createElement("ld-confirm-dropdown"); + dropdown.button = button; + document.body.appendChild(dropdown); +}); + +// Remove all confirm dropdowns when: +// - Turbo caches the page +// - The escape key is pressed +document.addEventListener("turbo:before-cache", removeAll); +document.addEventListener("keydown", (event) => { + if (event.key === "Escape") { + removeAll(); + } +}); + +class ConfirmDropdown extends LitElement { + constructor() { + super(); + this.confirmId = nextConfirmId(); + } + + createRenderRoot() { + return this; + } + + firstUpdated(props) { + super.firstUpdated(props); + this.classList.add("dropdown", "confirm-dropdown", "active"); + + const menu = this.querySelector(".menu"); + this.positionController = new PositionController({ + anchor: this.button, + overlay: menu, + arrow: this.querySelector(".menu-arrow"), + offset: 12, + }); + this.positionController.enable(); + this.focusTrap = new FocusTrapController(menu); + } + + render() { + const questionText = this.button.dataset.confirmQuestion || "Are you sure?"; + return html` + + `; + } + + confirm() { + this.button.closest("form").requestSubmit(this.button); + this.close(); + } + + close() { + this.positionController.disable(); + this.focusTrap.destroy(); + this.remove(); + this.button.focus({ focusVisible: isKeyboardActive() }); + } +} + +customElements.define("ld-confirm-dropdown", ConfirmDropdown); diff --git a/bookmarks/frontend/components/details-modal.js b/bookmarks/frontend/components/details-modal.js new file mode 100644 index 0000000..090a7bc --- /dev/null +++ b/bookmarks/frontend/components/details-modal.js @@ -0,0 +1,16 @@ +import { setAfterPageLoadFocusTarget } from "../utils/focus.js"; +import { Modal } from "./modal.js"; + +class DetailsModal extends Modal { + doClose() { + super.doClose(); + + // Try restore focus to view details to view details link of respective bookmark + const bookmarkId = this.dataset.bookmarkId; + setAfterPageLoadFocusTarget( + `ul.bookmark-list li[data-bookmark-id='${bookmarkId}'] a.view-action`, + ); + } +} + +customElements.define("ld-details-modal", DetailsModal); diff --git a/bookmarks/frontend/components/dropdown.js b/bookmarks/frontend/components/dropdown.js new file mode 100644 index 0000000..86bdf16 --- /dev/null +++ b/bookmarks/frontend/components/dropdown.js @@ -0,0 +1,76 @@ +class Dropdown extends HTMLElement { + constructor() { + super(); + this.opened = false; + this.onClick = this.onClick.bind(this); + this.onOutsideClick = this.onOutsideClick.bind(this); + this.onEscape = this.onEscape.bind(this); + this.onFocusOut = this.onFocusOut.bind(this); + } + + connectedCallback() { + // Defer setup to next frame when children are available in the DOM + requestAnimationFrame(() => { + // Prevent opening the dropdown automatically on focus, so that it only + // opens on click when JS is enabled + this.style.setProperty("--dropdown-focus-display", "none"); + this.addEventListener("keydown", this.onEscape); + this.addEventListener("focusout", this.onFocusOut); + + this.toggle = this.querySelector(".dropdown-toggle"); + this.toggle.setAttribute("aria-expanded", "false"); + this.toggle.addEventListener("click", this.onClick); + }); + } + + disconnectedCallback() { + this.close(); + this.toggle?.removeEventListener("click", this.onClick); + this.removeEventListener("keydown", this.onEscape); + this.removeEventListener("focusout", this.onFocusOut); + } + + open() { + this.opened = true; + this.classList.add("active"); + this.toggle.setAttribute("aria-expanded", "true"); + document.addEventListener("click", this.onOutsideClick); + } + + close() { + this.opened = false; + this.classList.remove("active"); + this.toggle?.setAttribute("aria-expanded", "false"); + document.removeEventListener("click", this.onOutsideClick); + } + + onClick() { + if (this.opened) { + this.close(); + } else { + this.open(); + } + } + + onOutsideClick(event) { + if (!this.contains(event.target)) { + this.close(); + } + } + + onEscape(event) { + if (event.key === "Escape" && this.opened) { + event.preventDefault(); + this.close(); + this.toggle.focus(); + } + } + + onFocusOut(event) { + if (!this.contains(event.relatedTarget)) { + this.close(); + } + } +} + +customElements.define("ld-dropdown", Dropdown); diff --git a/bookmarks/frontend/components/filter-drawer.js b/bookmarks/frontend/components/filter-drawer.js new file mode 100644 index 0000000..37d1a93 --- /dev/null +++ b/bookmarks/frontend/components/filter-drawer.js @@ -0,0 +1,109 @@ +import { html, render } from "lit"; +import { Modal } from "./modal.js"; +import { isKeyboardActive } from "../utils/focus.js"; + +class FilterDrawerTrigger extends HTMLElement { + connectedCallback() { + this.onClick = this.onClick.bind(this); + this.addEventListener("click", this.onClick.bind(this)); + } + + disconnectedCallback() { + this.removeEventListener("click", this.onClick.bind(this)); + } + + onClick() { + const modal = document.createElement("ld-filter-drawer"); + document.body.querySelector(".modals").appendChild(modal); + } +} + +customElements.define("ld-filter-drawer-trigger", FilterDrawerTrigger); + +class FilterDrawer extends Modal { + connectedCallback() { + this.classList.add("modal", "drawer", "filter-drawer"); + + // Render modal structure + render( + html` + + + `, + this, + ); + // Teleport filter content + this.teleport(); + // Force close on turbo cache to restore content + this.doClose = this.doClose.bind(this); + document.addEventListener("turbo:before-cache", this.doClose); + // Add active class to start slide-in animation + requestAnimationFrame(() => this.classList.add("active")); + // Call super after rendering to ensure elements are available + super.connectedCallback(); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.teleportBack(); + document.addEventListener("turbo:before-cache", 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.querySelector(".content"); + const sidePanel = document.querySelector(".side-panel"); + content.append(...sidePanel.children); + this.mapHeading(content, "h2", "h3"); + } + + teleportBack() { + const sidePanel = document.querySelector(".side-panel"); + const content = this.querySelector(".content"); + sidePanel.append(...content.children); + this.mapHeading(sidePanel, "h3", "h2"); + } + + doClose() { + super.doClose(); + + // Try restore focus to drawer trigger + const restoreFocusElement = + document.querySelector("ld-filter-drawer-trigger") || document.body; + restoreFocusElement.focus({ focusVisible: isKeyboardActive() }); + } +} + +customElements.define("ld-filter-drawer", FilterDrawer); diff --git a/bookmarks/frontend/components/form.js b/bookmarks/frontend/components/form.js new file mode 100644 index 0000000..212d3a3 --- /dev/null +++ b/bookmarks/frontend/components/form.js @@ -0,0 +1,73 @@ +class Form extends HTMLElement { + constructor() { + super(); + this.onKeyDown = this.onKeyDown.bind(this); + this.onChange = this.onChange.bind(this); + } + + connectedCallback() { + this.addEventListener("keydown", this.onKeyDown); + this.addEventListener("change", this.onChange); + + requestAnimationFrame(() => { + if (this.hasAttribute("data-form-reset")) { + // Resets form controls to their initial values before Turbo caches the DOM. + // Useful for filter forms where navigating back would otherwise still show + // values from after the form submission, which means the filters would be out + // of sync with the URL. + this.initFormReset(); + } + }); + } + + disconnectedCallback() { + this.removeEventListener("keydown", this.onKeyDown); + this.removeEventListener("change", this.onChange); + if (this.hasAttribute("data-form-reset")) { + this.resetForm(); + } + } + + onChange(event) { + if (event.target.hasAttribute("data-submit-on-change")) { + this.querySelector("form")?.requestSubmit(); + } + } + + onKeyDown(event) { + // Check for Ctrl/Cmd + Enter combination + if ( + this.hasAttribute("data-submit-on-ctrl-enter") && + event.key === "Enter" && + (event.metaKey || event.ctrlKey) + ) { + event.preventDefault(); + event.stopPropagation(); + this.querySelector("form")?.requestSubmit(); + } + } + + initFormReset() { + this.controls = this.querySelectorAll("input, select"); + this.controls.forEach((control) => { + if (control.type === "checkbox" || control.type === "radio") { + control.__initialValue = control.checked; + } else { + control.__initialValue = control.value; + } + }); + } + + resetForm() { + this.controls.forEach((control) => { + if (control.type === "checkbox" || control.type === "radio") { + control.checked = control.__initialValue; + } else { + control.value = control.__initialValue; + } + delete control.__initialValue; + }); + } +} + +customElements.define("ld-form", Form); diff --git a/bookmarks/frontend/behaviors/modal.js b/bookmarks/frontend/components/modal.js similarity index 50% rename from bookmarks/frontend/behaviors/modal.js rename to bookmarks/frontend/components/modal.js index 2f8c1d5..3366ec6 100644 --- a/bookmarks/frontend/behaviors/modal.js +++ b/bookmarks/frontend/components/modal.js @@ -1,24 +1,25 @@ -import { Behavior, registerBehavior } from "./index"; -import { FocusTrapController } from "./focus-utils"; +import { FocusTrapController } from "../utils/focus.js"; -export class ModalBehavior extends Behavior { - constructor(element) { - super(element); +export class Modal extends HTMLElement { + connectedCallback() { + requestAnimationFrame(() => { + this.onClose = this.onClose.bind(this); + this.onKeyDown = this.onKeyDown.bind(this); + this.overlay = this.querySelector(".modal-overlay"); + this.closeButton = this.querySelector(".modal-header .close"); - this.onClose = this.onClose.bind(this); - this.onKeyDown = this.onKeyDown.bind(this); + this.overlay.addEventListener("click", this.onClose); + this.closeButton.addEventListener("click", this.onClose); + document.addEventListener("keydown", this.onKeyDown); - 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.init(); + this.setupScrollLock(); + this.focusTrap = new FocusTrapController( + this.querySelector(".modal-container"), + ); + }); } - destroy() { + disconnectedCallback() { this.overlay.removeEventListener("click", this.onClose); this.closeButton.removeEventListener("click", this.onClose); document.removeEventListener("keydown", this.onKeyDown); @@ -27,13 +28,6 @@ export class ModalBehavior extends Behavior { this.focusTrap.destroy(); } - init() { - this.setupScrollLock(); - this.focusTrap = new FocusTrapController( - this.element.querySelector(".modal-container"), - ); - } - setupScrollLock() { document.body.classList.add("scroll-lock"); } @@ -61,8 +55,8 @@ export class ModalBehavior extends Behavior { onClose(event) { event.preventDefault(); - this.element.classList.add("closing"); - this.element.addEventListener( + this.classList.add("closing"); + this.addEventListener( "animationend", (event) => { if (event.animationName === "fade-out") { @@ -74,17 +68,16 @@ export class ModalBehavior extends Behavior { } doClose() { - this.element.remove(); - this.removeScrollLock(); - this.element.dispatchEvent(new CustomEvent("modal:close")); + this.remove(); + this.dispatchEvent(new CustomEvent("modal:close")); // Navigate to close URL - const closeUrl = this.element.dataset.closeUrl; - const frame = this.element.dataset.turboFrame; + const closeUrl = this.dataset.closeUrl; + const frame = this.dataset.turboFrame; if (closeUrl) { Turbo.visit(closeUrl, { action: "replace", frame: frame }); } } } -registerBehavior("ld-modal", ModalBehavior); +customElements.define("ld-modal", Modal); diff --git a/bookmarks/frontend/components/SearchAutocomplete.js b/bookmarks/frontend/components/search-autocomplete.js similarity index 84% rename from bookmarks/frontend/components/SearchAutocomplete.js rename to bookmarks/frontend/components/search-autocomplete.js index 9dffc0f..f7f4c39 100644 --- a/bookmarks/frontend/components/SearchAutocomplete.js +++ b/bookmarks/frontend/components/search-autocomplete.js @@ -1,23 +1,26 @@ -import { LitElement, html } from "lit"; -import { PositionController } from "../behaviors/position-controller"; -import { SearchHistory } from "./SearchHistory.js"; +import { html } from "lit"; import { api } from "../api.js"; -import { cache } from "../cache.js"; +import { TurboLitElement } from "../utils/element.js"; import { clampText, debounce, getCurrentWord, getCurrentWordBounds, -} from "../util.js"; +} from "../utils/input.js"; +import { PositionController } from "../utils/position-controller.js"; +import { SearchHistory } from "../utils/search-history.js"; +import { cache } from "../utils/tag-cache.js"; -export class SearchAutocomplete extends LitElement { +export class SearchAutocomplete extends TurboLitElement { static properties = { - name: { type: String }, - placeholder: { type: String }, - value: { type: String }, + inputName: { type: String, attribute: "input-name" }, + inputPlaceholder: { type: String, attribute: "input-placeholder" }, + inputValue: { type: String, attribute: "input-value" }, mode: { type: String }, - search: { type: Object }, - linkTarget: { type: String }, + user: { type: String }, + shared: { type: String }, + unread: { type: String }, + target: { type: String }, isFocus: { state: true }, isOpen: { state: true }, suggestions: { state: true }, @@ -26,12 +29,11 @@ export class SearchAutocomplete extends LitElement { constructor() { super(); - this.name = ""; - this.placeholder = ""; - this.value = ""; + this.inputName = ""; + this.inputPlaceholder = ""; + this.inputValue = ""; this.mode = ""; - this.search = {}; - this.linkTarget = "_blank"; + this.target = "_blank"; this.isFocus = false; this.isOpen = false; this.suggestions = { @@ -47,10 +49,6 @@ export class SearchAutocomplete extends LitElement { this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions()); } - createRenderRoot() { - return this; - } - firstUpdated() { this.style.setProperty("--menu-max-height", "400px"); this.input = this.querySelector("input"); @@ -81,7 +79,7 @@ export class SearchAutocomplete extends LitElement { } handleInput(e) { - this.value = e.target.value; + this.inputValue = e.target.value; this.debouncedLoadSuggestions(); } @@ -162,7 +160,7 @@ export class SearchAutocomplete extends LitElement { // Recent search suggestions const recentSearches = this.searchHistory - .getRecentSearches(this.value, 5) + .getRecentSearches(this.inputValue, 5) .map((value) => ({ type: "search", index: nextIndex(), @@ -173,11 +171,13 @@ export class SearchAutocomplete extends LitElement { // Bookmark suggestions let bookmarks = []; - if (this.value && this.value.length >= 3) { + if (this.inputValue && this.inputValue.length >= 3) { const path = this.mode ? `/${this.mode}` : ""; const suggestionSearch = { - ...this.search, - q: this.value, + user: this.user, + shared: this.shared, + unread: this.unread, + q: this.inputValue, }; const fetchedBookmarks = await api.listBookmarks(suggestionSearch, { limit: 5, @@ -219,11 +219,11 @@ export class SearchAutocomplete extends LitElement { completeSuggestion(suggestion) { if (suggestion.type === "search") { - this.value = suggestion.value; + this.inputValue = suggestion.value; this.close(); } if (suggestion.type === "bookmark") { - window.open(suggestion.bookmark.url, this.linkTarget); + window.open(suggestion.bookmark.url, this.target); this.close(); } if (suggestion.type === "tag") { @@ -293,10 +293,10 @@ export class SearchAutocomplete extends LitElement { { + this.button = this.querySelector('button[type="submit"]'); + this.button.addEventListener("click", this.onClick); + + this.fileInput = this.querySelector('input[type="file"]'); + this.fileInput.addEventListener("change", this.onChange); + }); + } + + disconnectedCallback() { + this.button.removeEventListener("click", this.onClick); + this.fileInput.removeEventListener("change", this.onChange); + } + + onClick(event) { + event.preventDefault(); + this.fileInput.click(); + } + + onChange() { + // Check if the file input has a file selected + if (!this.fileInput.files.length) { + return; + } + this.closest("form").requestSubmit(this.button); + // remove selected file so it doesn't get submitted again + this.fileInput.value = ""; + } +} + +customElements.define("ld-upload-button", UploadButton); diff --git a/bookmarks/frontend/index.js b/bookmarks/frontend/index.js index dfe13c7..5639a52 100644 --- a/bookmarks/frontend/index.js +++ b/bookmarks/frontend/index.js @@ -1,15 +1,13 @@ import "@hotwired/turbo"; -import "./behaviors/bookmark-page"; -import "./behaviors/bulk-edit"; -import "./behaviors/clear-button"; -import "./behaviors/confirm-button"; -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"; - -export { api } from "./api"; -export { cache } from "./cache"; +import "./components/bookmark-page.js"; +import "./components/clear-button.js"; +import "./components/confirm-dropdown.js"; +import "./components/details-modal.js"; +import "./components/dropdown.js"; +import "./components/filter-drawer.js"; +import "./components/form.js"; +import "./components/modal.js"; +import "./components/search-autocomplete.js"; +import "./components/tag-autocomplete.js"; +import "./components/upload-button.js"; +import "./shortcuts.js"; diff --git a/bookmarks/frontend/shortcuts.js b/bookmarks/frontend/shortcuts.js new file mode 100644 index 0000000..a223fb5 --- /dev/null +++ b/bookmarks/frontend/shortcuts.js @@ -0,0 +1,62 @@ +document.addEventListener("keydown", (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; + } + + // Handle shortcuts for navigating bookmarks with arrow keys + const isArrowUp = event.key === "ArrowUp"; + const isArrowDown = event.key === "ArrowDown"; + if (isArrowUp || isArrowDown) { + event.preventDefault(); + + // Detect current bookmark list item + const items = document.querySelectorAll("ul.bookmark-list > li"); + const path = event.composedPath(); + const currentItem = path.find((item) => items.includes(item)); + + // Find next item + let nextItem; + if (currentItem) { + nextItem = isArrowUp + ? currentItem.previousElementSibling + : currentItem.nextElementSibling; + } else { + // Select first item + nextItem = items[0]; + } + // Focus first link + if (nextItem) { + nextItem.querySelector("a").focus(); + } + } + + // Handle shortcut for toggling all notes + if (event.key === "e") { + const list = document.querySelector(".bookmark-list"); + if (list) { + list.classList.toggle("show-notes"); + } + } + + // Handle shortcut for focusing search input + if (event.key === "s") { + const searchInput = document.querySelector('input[type="search"]'); + + if (searchInput) { + searchInput.focus(); + event.preventDefault(); + } + } + + // Handle shortcut for adding new bookmark + if (event.key === "n") { + window.location.assign("/bookmarks/new"); + } +}); diff --git a/bookmarks/frontend/utils/element.js b/bookmarks/frontend/utils/element.js new file mode 100644 index 0000000..2cf82fc --- /dev/null +++ b/bookmarks/frontend/utils/element.js @@ -0,0 +1,44 @@ +import { LitElement } from "lit"; + +let isTopFrameVisit = false; + +document.addEventListener("turbo:visit", (event) => { + const url = event.detail.url; + isTopFrameVisit = + document.querySelector(`turbo-frame[src="${url}"][target="_top"]`) !== null; +}); + +document.addEventListener("turbo:render", () => { + isTopFrameVisit = false; +}); + +export class TurboLitElement extends LitElement { + constructor() { + super(); + this.__prepareForCache = this.__prepareForCache.bind(this); + } + + createRenderRoot() { + return this; // Render to light DOM + } + + connectedCallback() { + document.addEventListener("turbo:before-cache", this.__prepareForCache); + super.connectedCallback(); + } + + disconnectedCallback() { + document.removeEventListener("turbo:before-cache", this.__prepareForCache); + super.disconnectedCallback(); + } + + __prepareForCache() { + // Remove rendered contents before caching, otherwise restoring the DOM from + // cache will result in duplicated contents. Turbo also fires before-cache + // when rendering a frame that does target the top frame, in which case we + // want to keep the contents. + if (!isTopFrameVisit) { + this.innerHTML = ""; + } + } +} diff --git a/bookmarks/frontend/behaviors/focus-utils.js b/bookmarks/frontend/utils/focus.js similarity index 100% rename from bookmarks/frontend/behaviors/focus-utils.js rename to bookmarks/frontend/utils/focus.js diff --git a/bookmarks/frontend/util.js b/bookmarks/frontend/utils/input.js similarity index 86% rename from bookmarks/frontend/util.js rename to bookmarks/frontend/utils/input.js index 83347a6..5c9a974 100644 --- a/bookmarks/frontend/util.js +++ b/bookmarks/frontend/utils/input.js @@ -9,13 +9,6 @@ export function debounce(callback, delay = 250) { }; } -export function preventDefault(fn) { - return function (event) { - event.preventDefault(); - fn.call(this, event); - }; -} - export function clampText(text, maxChars = 30) { if (!text || text.length <= 30) return text; diff --git a/bookmarks/frontend/behaviors/position-controller.js b/bookmarks/frontend/utils/position-controller.js similarity index 100% rename from bookmarks/frontend/behaviors/position-controller.js rename to bookmarks/frontend/utils/position-controller.js diff --git a/bookmarks/frontend/components/SearchHistory.js b/bookmarks/frontend/utils/search-history.js similarity index 100% rename from bookmarks/frontend/components/SearchHistory.js rename to bookmarks/frontend/utils/search-history.js diff --git a/bookmarks/frontend/cache.js b/bookmarks/frontend/utils/tag-cache.js similarity index 88% rename from bookmarks/frontend/cache.js rename to bookmarks/frontend/utils/tag-cache.js index 0bcf9e5..4dfcd88 100644 --- a/bookmarks/frontend/cache.js +++ b/bookmarks/frontend/utils/tag-cache.js @@ -1,6 +1,6 @@ -import { api } from "./api.js"; +import { api } from "../api.js"; -class Cache { +class TagCache { constructor(api) { this.api = api; @@ -32,4 +32,4 @@ class Cache { } } -export const cache = new Cache(api); +export const cache = new TagCache(api); diff --git a/bookmarks/styles/bookmark-form.css b/bookmarks/styles/bookmark-form.css index 6b52aa4..fbdfd88 100644 --- a/bookmarks/styles/bookmark-form.css +++ b/bookmarks/styles/bookmark-form.css @@ -22,7 +22,7 @@ font-size: var(--font-size-sm); } - & .form-group .clear-button, + & .form-group ld-clear-button, & .form-group #refresh-button { display: none; } diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css index db069c8..c6aa021 100644 --- a/bookmarks/styles/bookmark-page.css +++ b/bookmarks/styles/bookmark-page.css @@ -15,7 +15,7 @@ grid-gap: var(--unit-9); } - [ld-filter-drawer-trigger] { + ld-filter-drawer-trigger { display: none; } @@ -24,7 +24,7 @@ display: none; } - [ld-filter-drawer-trigger] { + ld-filter-drawer-trigger { display: inline-block; } } @@ -38,7 +38,7 @@ display: none; } - [ld-filter-drawer-trigger] { + ld-filter-drawer-trigger { display: inline-block; } } @@ -60,32 +60,6 @@ margin-left: 0; } - /* Regular input */ - - & input[type="search"] { - height: var(--control-size); - -webkit-appearance: none; - } - - /* Enhanced auto-complete input */ - /* This needs a bit more wrangling to make the CSS component align with the attached button */ - - & .form-autocomplete { - height: var(--control-size); - - & .form-autocomplete-input { - width: 100%; - height: var(--control-size); - - & input[type="search"] { - width: 100%; - height: 100%; - margin: 0; - border: none; - } - } - } - /* Group search options button with search button */ height: var(--control-size); border-radius: var(--border-radius); @@ -151,15 +125,6 @@ } /* Bookmark list */ -ul.bookmark-list { - list-style: none; - margin: 0; - padding: 0; - - /* Increase line-height for better separation within / between items */ - line-height: 1.1rem; -} - @keyframes appear { 0% { opacity: 0; @@ -172,177 +137,182 @@ ul.bookmark-list { } } -/* Bookmarks */ -li[ld-bookmark-item] { - position: relative; - display: flex; - gap: var(--unit-2); - margin-top: 0; - margin-bottom: var(--unit-3); +ul.bookmark-list { + list-style: none; + margin: 0; + padding: 0; - & .content { - flex: 1 1 0; - min-width: 0; - } + /* Increase line-height for better separation within / between items */ + line-height: 1.1rem; - & .preview-image { - flex: 0 0 auto; - width: 100px; - height: 60px; - margin-top: var(--unit-h); - border-radius: var(--border-radius); - border: solid 1px var(--border-color); - object-fit: cover; + > li { + position: relative; + display: flex; + gap: var(--unit-2); + margin-top: 0; + margin-bottom: var(--unit-3); - &.placeholder { - display: flex; - align-items: center; - justify-content: center; - background: var(--body-color-contrast); + & .content { + flex: 1 1 0; + min-width: 0; + } - & .img { - width: var(--unit-12); - height: var(--unit-12); - background-color: var(--tertiary-text-color); - -webkit-mask: url(preview-placeholder.svg) no-repeat center; - mask: url(preview-placeholder.svg) no-repeat center; + & .preview-image { + flex: 0 0 auto; + width: 100px; + height: 60px; + margin-top: var(--unit-h); + border-radius: var(--border-radius); + border: solid 1px var(--border-color); + object-fit: cover; + + &.placeholder { + display: flex; + align-items: center; + justify-content: center; + background: var(--body-color-contrast); + + & .img { + width: var(--unit-12); + height: var(--unit-12); + background-color: var(--tertiary-text-color); + -webkit-mask: url(preview-placeholder.svg) no-repeat center; + mask: url(preview-placeholder.svg) no-repeat center; + } } } - } - & .form-checkbox.bulk-edit-checkbox { - display: none; - } - - & .title { - position: relative; - } - - & .title img { - position: absolute; - width: 16px; - height: 16px; - left: 0; - top: 50%; - transform: translateY(-50%); - pointer-events: none; - } - - & .title img + a { - padding-left: 22px; - } - - & .title a { - color: var(--bookmark-title-color); - font-weight: var(--bookmark-title-weight); - display: block; - width: 100%; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - } - - & .title a[data-tooltip]:hover::after, - & .title a[data-tooltip]:focus::after { - content: attr(data-tooltip); - position: absolute; - z-index: 10; - top: 100%; - left: 50%; - transform: translateX(-50%); - width: max-content; - max-width: 90%; - height: fit-content; - background-color: #292f62; - color: #fff; - padding: var(--unit-1); - border-radius: var(--border-radius); - border: 1px solid #424a8c; - font-size: var(--font-size-sm); - font-style: normal; - white-space: normal; - pointer-events: none; - animation: 0.3s ease 0s appear; - } - - @media (pointer: coarse) { - & .title a[data-tooltip]::after { - display: none; + & .title { + position: relative; } - } - &.unread .title a { - font-style: italic; - } - - & .url-path, - & .url-display { - font-size: var(--font-size-sm); - color: var(--secondary-link-color); - } - - & .description { - color: var(--bookmark-description-color); - font-weight: var(--bookmark-description-weight); - } - - & .description.separate { - display: -webkit-box; - -webkit-box-orient: vertical; - -webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1); - overflow: hidden; - } - - & .tags { - & a, - & a:visited:hover { - color: var(--alternative-color); + & .title img { + position: absolute; + width: 16px; + height: 16px; + left: 0; + top: 50%; + transform: translateY(-50%); + pointer-events: none; } - } - & .actions, - & .extra-actions { - display: flex; - align-items: baseline; - flex-wrap: wrap; - column-gap: var(--unit-2); - } + & .title img + a { + padding-left: 22px; + } - @media (max-width: 600px) { - & .extra-actions { + & .title a { + color: var(--bookmark-title-color); + font-weight: var(--bookmark-title-weight); + display: block; width: 100%; - margin-top: var(--unit-1); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } - } - & .actions { - color: var(--bookmark-actions-color); - font-size: var(--font-size-sm); + & .title a[data-tooltip]:hover::after, + & .title a[data-tooltip]:focus::after { + content: attr(data-tooltip); + position: absolute; + z-index: 10; + top: 100%; + left: 50%; + transform: translateX(-50%); + width: max-content; + max-width: 90%; + height: fit-content; + background-color: #292f62; + color: #fff; + padding: var(--unit-1); + border-radius: var(--border-radius); + border: 1px solid #424a8c; + font-size: var(--font-size-sm); + font-style: normal; + white-space: normal; + pointer-events: none; + animation: 0.3s ease 0s appear; + } - & a, - & button.btn-link { + @media (pointer: coarse) { + & .title a[data-tooltip]::after { + display: none; + } + } + + &.unread .title a { + font-style: italic; + } + + & .url-path, + & .url-display { + font-size: var(--font-size-sm); + color: var(--secondary-link-color); + } + + & .description { + color: var(--bookmark-description-color); + font-weight: var(--bookmark-description-weight); + } + + & .description.separate { + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: var(--ld-bookmark-description-max-lines, 1); + overflow: hidden; + } + + & .tags { + & a, + & a:visited:hover { + color: var(--alternative-color); + } + } + + & .actions, + & .extra-actions { + display: flex; + align-items: baseline; + flex-wrap: wrap; + column-gap: var(--unit-2); + } + + @media (max-width: 600px) { + & .extra-actions { + width: 100%; + margin-top: var(--unit-1); + } + } + + & .actions { color: var(--bookmark-actions-color); - --btn-icon-color: var(--bookmark-actions-color); - font-weight: var(--bookmark-actions-weight); - padding: 0; - height: auto; - vertical-align: unset; - border: none; - box-sizing: border-box; - transition: none; - text-decoration: none; + font-size: var(--font-size-sm); - &:focus, - &:hover, - &:active, - &.active { - color: var(--bookmark-actions-hover-color); - --btn-icon-color: var(--bookmark-actions-hover-color); + & a, + & button.btn-link { + color: var(--bookmark-actions-color); + --btn-icon-color: var(--bookmark-actions-color); + font-weight: var(--bookmark-actions-weight); + padding: 0; + height: auto; + vertical-align: unset; + border: none; + box-sizing: border-box; + transition: none; + text-decoration: none; + + &:focus, + &:hover, + &:active, + &.active { + color: var(--bookmark-actions-hover-color); + --btn-icon-color: var(--bookmark-actions-hover-color); + } } } } } +/* Bookmark pagination */ .bookmark-pagination { margin-top: var(--unit-4); @@ -456,7 +426,7 @@ ul.bookmark-list { --bulk-edit-transition-duration: 400ms; } -[ld-bulk-edit] { +ld-bookmark-page { & .bulk-edit-bar { margin-top: -1px; margin-left: calc(-1 * var(--bulk-edit-bar-offset)); @@ -504,7 +474,7 @@ ul.bookmark-list { /* Bookmark checkboxes */ - & li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox { + & .form-checkbox.bulk-edit-checkbox:not(.all) { display: block; position: absolute; width: var(--bulk-edit-toggle-width); @@ -525,7 +495,7 @@ ul.bookmark-list { } } - &.active li[ld-bookmark-item] .form-checkbox.bulk-edit-checkbox { + &.active .form-checkbox.bulk-edit-checkbox:not(.all) { visibility: visible; opacity: 1; } @@ -560,4 +530,15 @@ ul.bookmark-list { font-size: var(--font-size-sm); } } + + & ld-tag-autocomplete { + display: none; + } + + &[data-bulk-action="bulk_tag"], + &[data-bulk-action="bulk_untag"] { + & ld-tag-autocomplete { + display: inline-block; + } + } } diff --git a/bookmarks/styles/theme/autocomplete.css b/bookmarks/styles/theme/autocomplete.css index 2ce7ea0..96952bd 100644 --- a/bookmarks/styles/theme/autocomplete.css +++ b/bookmarks/styles/theme/autocomplete.css @@ -23,7 +23,7 @@ flex: 1 0 auto; line-height: var(--unit-4); width: 100%; - height: 100%; + height: 100% !important; margin: 0; border: none; diff --git a/bookmarks/templates/bookmarks/archive.html b/bookmarks/templates/bookmarks/archive.html index 5e699d5..a0d632c 100644 --- a/bookmarks/templates/bookmarks/archive.html +++ b/bookmarks/templates/bookmarks/archive.html @@ -4,8 +4,8 @@ {% load bookmarks %} {% block content %} -
+ {# Bookmark list #}
@@ -14,7 +14,9 @@
{% bookmark_search bookmark_list.search mode='archived' %} {% include 'bookmarks/bulk_edit/toggle.html' %} - + + +
@@ -35,7 +37,7 @@ {% include 'bookmarks/bundle_section.html' %} {% include 'bookmarks/tag_section.html' %} - + {% endblock %} {% block overlays %} diff --git a/bookmarks/templates/bookmarks/bookmark_list.html b/bookmarks/templates/bookmarks/bookmark_list.html index 7af15d4..41d76c6 100644 --- a/bookmarks/templates/bookmarks/bookmark_list.html +++ b/bookmarks/templates/bookmarks/bookmark_list.html @@ -11,14 +11,16 @@ style="--ld-bookmark-description-max-lines:{{ bookmark_list.description_max_lines }};" data-bookmarks-total="{{ bookmark_list.bookmarks_total }}"> {% for bookmark_item in bookmark_list.items %} -
  • - + {% if not bookmark_list.is_preview %} + + {% endif %} {% if bookmark_item.favicon_file and bookmark_list.show_favicons %} {% endif %} @@ -85,7 +87,7 @@ {# View link is visible for both owned and shared bookmarks #} {% if bookmark_list.show_view_action %} View + data-turbo-action="replace" data-turbo-frame="details-modal">View {% endif %} {% if bookmark_item.is_editable %} {# Bookmark owner actions #} @@ -104,7 +106,7 @@ {% endif %} {% endif %} {% if bookmark_list.show_remove_action %} - {% endif %} @@ -120,7 +122,7 @@ {% if bookmark_item.show_mark_as_read %} diff --git a/bookmarks/templates/bookmarks/bundle_section.html b/bookmarks/templates/bookmarks/bundle_section.html index 74aa29b..e3ec1cd 100644 --- a/bookmarks/templates/bookmarks/bundle_section.html +++ b/bookmarks/templates/bookmarks/bundle_section.html @@ -2,7 +2,7 @@

    Bundles

    -
  • {% endif %} - + - - +
    {% csrf_token %} @@ -58,7 +58,7 @@ -
    + {% endhtmlmin %} diff --git a/bookmarks/templates/bookmarks/new.html b/bookmarks/templates/bookmarks/new.html index 58ccfa9..07baeaf 100644 --- a/bookmarks/templates/bookmarks/new.html +++ b/bookmarks/templates/bookmarks/new.html @@ -12,9 +12,11 @@

    New bookmark

    -
    - {% include 'bookmarks/form.html' %} -
    + +
    + {% include 'bookmarks/form.html' %} +
    +
    {% endblock %} diff --git a/bookmarks/templates/bookmarks/search.html b/bookmarks/templates/bookmarks/search.html index 009bdc6..776d167 100644 --- a/bookmarks/templates/bookmarks/search.html +++ b/bookmarks/templates/bookmarks/search.html @@ -1,20 +1,23 @@ {% load widget_tweaks %} -
    +
    - -
    +
    diff --git a/bookmarks/templates/bookmarks/shared.html b/bookmarks/templates/bookmarks/shared.html index c1d7764..120d8b8 100644 --- a/bookmarks/templates/bookmarks/shared.html +++ b/bookmarks/templates/bookmarks/shared.html @@ -4,7 +4,7 @@ {% load bookmarks %} {% block content %} -
    {# Bookmark list #} @@ -13,7 +13,9 @@

    Shared bookmarks

    {% bookmark_search bookmark_list.search mode='shared' %} - + + +
    @@ -40,7 +42,7 @@ {% include 'bookmarks/tag_section.html' %} - + {% endblock %} {% block overlays %} diff --git a/bookmarks/templates/bookmarks/tag_section.html b/bookmarks/templates/bookmarks/tag_section.html index 4e57717..bbc6bc7 100644 --- a/bookmarks/templates/bookmarks/tag_section.html +++ b/bookmarks/templates/bookmarks/tag_section.html @@ -2,7 +2,7 @@

    Tags

    {% if user.is_authenticated %} - + {% endif %}
    diff --git a/bookmarks/templates/bookmarks/user_select.html b/bookmarks/templates/bookmarks/user_select.html index 2f16748..5b2d67e 100644 --- a/bookmarks/templates/bookmarks/user_select.html +++ b/bookmarks/templates/bookmarks/user_select.html @@ -1,15 +1,17 @@ {% load widget_tweaks %} -
    - {% for hidden_field in form.hidden_fields %} - {{ hidden_field }} - {% endfor %} -
    -
    - {% render_field form.user class+="form-select" ld-auto-submit="" %} - + + + {% for hidden_field in form.hidden_fields %} + {{ hidden_field }} + {% endfor %} +
    +
    + {% render_field form.user class+="form-select" data-submit-on-change="" %} + +
    -
    - + + \ No newline at end of file diff --git a/bookmarks/templates/bundles/form.html b/bookmarks/templates/bundles/form.html index a41f64c..67ecec7 100644 --- a/bookmarks/templates/bundles/form.html +++ b/bookmarks/templates/bundles/form.html @@ -23,25 +23,31 @@
    -
    +
    - {{ form.any_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} + +
    At least one of these tags must be present in a bookmark to match.
    -
    +
    - {{ form.all_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} + +
    All of these tags must be present in a bookmark to match.
    -
    +
    - {{ form.excluded_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off" }} + +
    None of these tags must be present in a bookmark to match.
    @@ -54,37 +60,37 @@
    diff --git a/bookmarks/templates/bundles/index.html b/bookmarks/templates/bundles/index.html index f42fe1e..21ebb0d 100644 --- a/bookmarks/templates/bundles/index.html +++ b/bookmarks/templates/bundles/index.html @@ -47,7 +47,7 @@ Edit - diff --git a/bookmarks/templates/settings/create_api_token_modal.html b/bookmarks/templates/settings/create_api_token_modal.html index 544e478..d543694 100644 --- a/bookmarks/templates/settings/create_api_token_modal.html +++ b/bookmarks/templates/settings/create_api_token_modal.html @@ -2,7 +2,7 @@
    {% csrf_token %} - -
    + diff --git a/bookmarks/templates/settings/integrations.html b/bookmarks/templates/settings/integrations.html index 2fe484f..ed392df 100644 --- a/bookmarks/templates/settings/integrations.html +++ b/bookmarks/templates/settings/integrations.html @@ -130,7 +130,7 @@ {{ token.created|date:"M d, Y H:i" }} {% csrf_token %} - diff --git a/bookmarks/templates/tags/index.html b/bookmarks/templates/tags/index.html index 4b648b9..5ed2176 100644 --- a/bookmarks/templates/tags/index.html +++ b/bookmarks/templates/tags/index.html @@ -23,40 +23,42 @@ {# Filters #}
    -
    -
    - -
    - - + + +
    + +
    + + +
    -
    -
    - -
    +
    + +
    - + +
    -
    -
    - -
    - - +
    + +
    + + {# Tags count #}

    {% if search or unused_only %} @@ -96,7 +98,7 @@ Edit diff --git a/bookmarks/templates/tags/merge.html b/bookmarks/templates/tags/merge.html index 2479ad5..5d7244b 100644 --- a/bookmarks/templates/tags/merge.html +++ b/bookmarks/templates/tags/merge.html @@ -30,9 +30,11 @@

    {% csrf_token %} -
    +
    - {{ form.target_tag|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }} + +
    Enter the name of the tag you want to keep. The tags entered below will be merged into this one.
    @@ -43,9 +45,11 @@ {% endif %}
    -
    +
    - {{ form.merge_tags|add_class:"form-input"|attr:"autocomplete:off"|attr:"autocapitalize:off"|attr:"placeholder: " }} + +
    Enter the names of tags to merge into the target tag, separated by spaces. These tags will be deleted after merging.
    diff --git a/bookmarks/tests/helpers.py b/bookmarks/tests/helpers.py index 51d2218..d2715bc 100644 --- a/bookmarks/tests/helpers.py +++ b/bookmarks/tests/helpers.py @@ -315,12 +315,12 @@ class BookmarkListTestMixin(TestCase, HtmlTestMixin): ) self.assertIsNotNone(bookmark_list) - bookmark_items = bookmark_list.select("li[ld-bookmark-item]") + bookmark_items = bookmark_list.select("ul.bookmark-list > li") self.assertEqual(len(bookmark_items), len(bookmarks)) for bookmark in bookmarks: bookmark_item = bookmark_list.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' + f'ul.bookmark-list > li a[href="{bookmark.url}"][target="{link_target}"]' ) self.assertIsNotNone(bookmark_item) @@ -331,7 +331,7 @@ class BookmarkListTestMixin(TestCase, HtmlTestMixin): for bookmark in bookmarks: bookmark_item = soup.select_one( - f'li[ld-bookmark-item] a[href="{bookmark.url}"][target="{link_target}"]' + f'ul.bookmark-list > li a[href="{bookmark.url}"][target="{link_target}"]' ) self.assertIsNone(bookmark_item) diff --git a/bookmarks/tests/test_bookmark_archived_view_performance.py b/bookmarks/tests/test_bookmark_archived_view_performance.py index a3663f1..cc3784a 100644 --- a/bookmarks/tests/test_bookmark_archived_view_performance.py +++ b/bookmarks/tests/test_bookmark_archived_view_performance.py @@ -34,7 +34,7 @@ class BookmarkArchivedViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.archived")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual(len(list_items), num_initial_bookmarks) number_of_queries = context.final_queries @@ -49,7 +49,7 @@ class BookmarkArchivedViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.archived")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual( len(list_items), num_initial_bookmarks + num_additional_bookmarks ) diff --git a/bookmarks/tests/test_bookmark_details_modal.py b/bookmarks/tests/test_bookmark_details_modal.py index 51a2d85..141093c 100644 --- a/bookmarks/tests/test_bookmark_details_modal.py +++ b/bookmarks/tests/test_bookmark_details_modal.py @@ -24,17 +24,17 @@ class BookmarkDetailsModalTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin url = reverse("linkding:bookmarks.index") + f"?details={bookmark.id}" response = self.client.get(url) soup = self.make_soup(response.content.decode()) - return soup.select_one("div.modal.bookmark-details") + return soup.select_one("ld-details-modal") def get_shared_details_modal(self, bookmark): url = reverse("linkding:bookmarks.shared") + f"?details={bookmark.id}" response = self.client.get(url) soup = self.make_soup(response.content.decode()) - return soup.select_one("div.modal.bookmark-details") + return soup.select_one("ld-details-modal") def has_details_modal(self, response): soup = self.make_soup(response.content.decode()) - return soup.select_one("div.modal.bookmark-details") is not None + return soup.select_one("ld-details-modal") is not None def find_section_content(self, soup, section_name): h3 = soup.find("h3", string=section_name) diff --git a/bookmarks/tests/test_bookmark_edit_view.py b/bookmarks/tests/test_bookmark_edit_view.py index da7dc67..7b442ab 100644 --- a/bookmarks/tests/test_bookmark_edit_view.py +++ b/bookmarks/tests/test_bookmark_edit_view.py @@ -122,8 +122,9 @@ class BookmarkEditViewTestCase(TestCase, BookmarkFactoryMixin): tag_string = build_tag_string(bookmark.tag_names, " ") self.assertInHTML( f""" - + + """, html, ) diff --git a/bookmarks/tests/test_bookmark_index_view_performance.py b/bookmarks/tests/test_bookmark_index_view_performance.py index 2c7c15b..3e16fe3 100644 --- a/bookmarks/tests/test_bookmark_index_view_performance.py +++ b/bookmarks/tests/test_bookmark_index_view_performance.py @@ -34,7 +34,7 @@ class BookmarkIndexViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.index")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual(len(list_items), num_initial_bookmarks) number_of_queries = context.final_queries @@ -49,7 +49,7 @@ class BookmarkIndexViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.index")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual( len(list_items), num_initial_bookmarks + num_additional_bookmarks ) diff --git a/bookmarks/tests/test_bookmark_new_view.py b/bookmarks/tests/test_bookmark_new_view.py index a892197..e4a8fb0 100644 --- a/bookmarks/tests/test_bookmark_new_view.py +++ b/bookmarks/tests/test_bookmark_new_view.py @@ -118,8 +118,9 @@ class BookmarkNewViewTestCase(TestCase, BookmarkFactoryMixin): self.assertInHTML( """ - + + """, html, ) diff --git a/bookmarks/tests/test_bookmark_search_tag.py b/bookmarks/tests/test_bookmark_search_tag.py index 3ab8a9a..8cde66a 100644 --- a/bookmarks/tests/test_bookmark_search_tag.py +++ b/bookmarks/tests/test_bookmark_search_tag.py @@ -38,11 +38,11 @@ class BookmarkSearchTagTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.assertIsNone(element) def assertSearchInput(self, form: BeautifulSoup, name: str, value: str = None): - element = form.select_one(f'input[name="{name}"][type="search"]') + element = form.select_one(f'ld-search-autocomplete[input-name="{name}"]') self.assertIsNotNone(element) if value is not None: - self.assertEqual(element["value"], value) + self.assertEqual(element["input-value"], value) def assertSelect(self, form: BeautifulSoup, name: str, value: str = None): select = form.select_one(f'select[name="{name}"]') diff --git a/bookmarks/tests/test_bookmark_shared_view.py b/bookmarks/tests/test_bookmark_shared_view.py index c0a25bb..a3f2680 100644 --- a/bookmarks/tests/test_bookmark_shared_view.py +++ b/bookmarks/tests/test_bookmark_shared_view.py @@ -38,7 +38,7 @@ class BookmarkSharedViewTestCase( f'' ) user_select_html = f""" - {''.join(user_options)} """ diff --git a/bookmarks/tests/test_bookmark_shared_view_performance.py b/bookmarks/tests/test_bookmark_shared_view_performance.py index 7561c83..748c9ad 100644 --- a/bookmarks/tests/test_bookmark_shared_view_performance.py +++ b/bookmarks/tests/test_bookmark_shared_view_performance.py @@ -35,7 +35,7 @@ class BookmarkSharedViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.shared")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual(len(list_items), num_initial_bookmarks) number_of_queries = context.final_queries @@ -51,7 +51,7 @@ class BookmarkSharedViewPerformanceTestCase( response = self.client.get(reverse("linkding:bookmarks.shared")) html = response.content.decode("utf-8") soup = self.make_soup(html) - list_items = soup.select("li[ld-bookmark-item]") + list_items = soup.select("ul.bookmark-list > li") self.assertEqual( len(list_items), num_initial_bookmarks + num_additional_bookmarks ) diff --git a/bookmarks/tests/test_bookmarks_list_template.py b/bookmarks/tests/test_bookmarks_list_template.py index 758ef72..875d976 100644 --- a/bookmarks/tests/test_bookmarks_list_template.py +++ b/bookmarks/tests/test_bookmarks_list_template.py @@ -97,7 +97,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): def assertDeleteLinkCount(self, html: str, bookmark: Bookmark, count=1): self.assertInHTML( f""" - """, html, @@ -231,7 +231,7 @@ class BookmarkListTemplateTest(TestCase, BookmarkFactoryMixin, HtmlTestMixin): f""" + """ diff --git a/bookmarks/tests/test_bundles_new_view.py b/bookmarks/tests/test_bundles_new_view.py index f6c19f4..f79680d 100644 --- a/bookmarks/tests/test_bundles_new_view.py +++ b/bookmarks/tests/test_bundles_new_view.py @@ -84,10 +84,10 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): soup = self.make_soup(response.content.decode()) search_field = soup.select_one('input[name="search"]') - all_tags_field = soup.select_one('input[name="all_tags"]') + all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]') self.assertEqual(search_field.get("value"), "machine learning") - self.assertEqual(all_tags_field.get("value"), "python ai") + self.assertEqual(all_tags_field.get("input-value"), "python ai") def test_should_ignore_special_search_commands(self): query = "python tutorial !untagged !unread" @@ -96,20 +96,20 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): soup = self.make_soup(response.content.decode()) search_field = soup.select_one('input[name="search"]') - all_tags_field = soup.select_one('input[name="all_tags"]') + all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]') self.assertEqual(search_field.get("value"), "python tutorial") - self.assertIsNone(all_tags_field.get("value")) + self.assertEqual(all_tags_field.get("input-value"), "") def test_should_not_prefill_when_no_query_parameter(self): response = self.client.get(reverse("linkding:bundles.new")) soup = self.make_soup(response.content.decode()) search_field = soup.select_one('input[name="search"]') - all_tags_field = soup.select_one('input[name="all_tags"]') + all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]') self.assertIsNone(search_field.get("value")) - self.assertIsNone(all_tags_field.get("value")) + self.assertEqual(all_tags_field.get("input-value"), "") def test_should_not_prefill_when_editing_existing_bundle(self): bundle = self.setup_bundle( @@ -126,10 +126,10 @@ class BundleNewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): soup = self.make_soup(response.content.decode()) search_field = soup.select_one('input[name="search"]') - all_tags_field = soup.select_one('input[name="all_tags"]') + all_tags_field = soup.select_one('ld-tag-autocomplete[input-name="all_tags"]') self.assertEqual(search_field.get("value"), "Tutorial") - self.assertEqual(all_tags_field.get("value"), "java spring") + self.assertEqual(all_tags_field.get("input-value"), "java spring") def test_should_show_correct_preview_with_prefilled_values(self): bundle_tag = self.setup_tag() diff --git a/bookmarks/tests/test_bundles_preview_view.py b/bookmarks/tests/test_bundles_preview_view.py index 3eb6a0a..4f03459 100644 --- a/bookmarks/tests/test_bundles_preview_view.py +++ b/bookmarks/tests/test_bundles_preview_view.py @@ -77,7 +77,7 @@ class BundlePreviewViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): response = self.client.get(reverse("linkding:bundles.preview")) soup = self.make_soup(response.content.decode()) - list_item = soup.select_one("li[ld-bookmark-item]") + list_item = soup.select_one("ul.bookmark-list > li") actions = list_item.select(".actions > *") self.assertEqual(len(actions), 1) diff --git a/bookmarks/tests/test_tags_index_view.py b/bookmarks/tests/test_tags_index_view.py index b9da9bf..19f3572 100644 --- a/bookmarks/tests/test_tags_index_view.py +++ b/bookmarks/tests/test_tags_index_view.py @@ -255,7 +255,7 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.assertInHTML( """ - @@ -270,7 +270,7 @@ class TagsIndexViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): self.assertInHTML( """ - diff --git a/bookmarks/tests/test_tags_merge_view.py b/bookmarks/tests/test_tags_merge_view.py index 54d40d6..50e70f1 100644 --- a/bookmarks/tests/test_tags_merge_view.py +++ b/bookmarks/tests/test_tags_merge_view.py @@ -15,6 +15,11 @@ class TagsMergeViewTestCase(TestCase, BookmarkFactoryMixin, HtmlTestMixin): input_element = soup.find("input", {"name": input_name}) if input_element: return input_element.find_parent("div", class_="form-group") + autocomplete_element = soup.find( + "ld-tag-autocomplete", {"input-name": input_name} + ) + if autocomplete_element: + return autocomplete_element.find_parent("div", class_="form-group") return None def test_merge_tags(self): diff --git a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py index ea7a602..235f7c0 100644 --- a/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py +++ b/bookmarks/tests_e2e/e2e_test_bookmark_details_modal.py @@ -144,7 +144,6 @@ class BookmarkDetailsModalE2ETestCase(LinkdingE2ETestCase): self.locate_confirm_dialog().get_by_text("Confirm").click() # verify bookmark is deleted - self.locate_bookmark(bookmark.title) expect(self.locate_bookmark(bookmark.title)).not_to_be_visible() self.assertEqual(Bookmark.objects.count(), 0) diff --git a/bookmarks/tests_e2e/e2e_test_bookmark_page_bulk_edit.py b/bookmarks/tests_e2e/e2e_test_bookmark_page_bulk_edit.py index dc4b022..ab1c3f9 100644 --- a/bookmarks/tests_e2e/e2e_test_bookmark_page_bulk_edit.py +++ b/bookmarks/tests_e2e/e2e_test_bookmark_page_bulk_edit.py @@ -5,7 +5,7 @@ from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase from bookmarks.models import Bookmark -class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): +class BookmarkPageBulkEditE2ETestCase(LinkdingE2ETestCase): def setup_test_data(self): self.setup_numbered_bookmarks(50) self.setup_numbered_bookmarks(50, archived=True) @@ -39,7 +39,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index"), p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_across().click() @@ -48,7 +48,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_bar().get_by_text("Execute").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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) self.assertEqual( 0, @@ -77,7 +77,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived"), p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_across().click() @@ -86,7 +86,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_bar().get_by_text("Execute").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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) self.assertEqual( 50, @@ -115,7 +115,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): with sync_playwright() as p: self.open(reverse("linkding:bookmarks.index") + "?q=foo", p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_across().click() @@ -124,7 +124,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_bar().get_by_text("Execute").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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) self.assertEqual( 50, @@ -153,7 +153,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): with sync_playwright() as p: self.open(reverse("linkding:bookmarks.archived") + "?q=foo", p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_select_all().click() self.locate_bulk_edit_select_across().click() @@ -162,7 +162,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_bar().get_by_text("Execute").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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) self.assertEqual( 50, @@ -281,7 +281,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): url = reverse("linkding:bookmarks.index") page = self.open(url, p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() # Select all bookmarks, enable select across self.locate_bulk_edit_toggle().click() @@ -294,7 +294,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): 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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) # Verify bulk edit checkboxes are reset checkboxes = page.locator("label.bulk-edit-checkbox input") @@ -313,7 +313,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): url = reverse("linkding:bookmarks.index") self.open(url, p) - bookmark_list = self.locate_bookmark_list() + bookmark_list = self.locate_bookmark_list().element_handle() self.locate_bulk_edit_toggle().click() self.locate_bulk_edit_select_all().click() @@ -325,7 +325,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): self.locate_bulk_edit_bar().get_by_text("Execute").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() + bookmark_list.wait_for_element_state("hidden", timeout=1000) expect(self.locate_bulk_edit_select_all()).not_to_be_checked() self.locate_bulk_edit_select_all().click() diff --git a/bookmarks/tests_e2e/e2e_test_bookmark_page_partial_updates.py b/bookmarks/tests_e2e/e2e_test_bookmark_page_partial_updates.py index a522e36..c1fbae3 100644 --- a/bookmarks/tests_e2e/e2e_test_bookmark_page_partial_updates.py +++ b/bookmarks/tests_e2e/e2e_test_bookmark_page_partial_updates.py @@ -24,7 +24,7 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): ) def assertVisibleBookmarks(self, titles: List[str]): - bookmark_tags = self.page.locator("li[ld-bookmark-item]") + bookmark_tags = self.page.locator("ul.bookmark-list > li") expect(bookmark_tags).to_have_count(len(titles)) for title in titles: @@ -59,12 +59,12 @@ class BookmarkPagePartialUpdatesE2ETestCase(LinkdingE2ETestCase): url = reverse("linkding:bookmarks.index") + "?sort=title_asc" page = self.open(url, p) - first_item = page.locator("li[ld-bookmark-item]").first + first_item = page.locator("ul.bookmark-list > li").first expect(first_item).to_contain_text("foo 1") first_item.get_by_text("Archive").click() - first_item = page.locator("li[ld-bookmark-item]").first + first_item = page.locator("ul.bookmark-list > li").first expect(first_item).to_contain_text("foo 2") def test_partial_update_respects_page(self): diff --git a/bookmarks/tests_e2e/e2e_test_collapse_side_panel.py b/bookmarks/tests_e2e/e2e_test_collapse_side_panel.py index a843ac0..569d98d 100644 --- a/bookmarks/tests_e2e/e2e_test_collapse_side_panel.py +++ b/bookmarks/tests_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-filter-drawer-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-filter-drawer-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/tests_e2e/e2e_test_dropdown.py b/bookmarks/tests_e2e/e2e_test_dropdown.py new file mode 100644 index 0000000..884bcce --- /dev/null +++ b/bookmarks/tests_e2e/e2e_test_dropdown.py @@ -0,0 +1,128 @@ +from django.urls import reverse +from playwright.sync_api import sync_playwright, expect + +from bookmarks.tests_e2e.helpers import LinkdingE2ETestCase + + +class DropdownE2ETestCase(LinkdingE2ETestCase): + def locate_dropdown(self): + # Use bookmarks dropdown as an example instance to test + return self.page.locator("ld-dropdown").filter( + has=self.page.get_by_role("button", name="Bookmarks") + ) + + def locate_dropdown_toggle(self): + return self.locate_dropdown().locator(".dropdown-toggle") + + def locate_dropdown_menu(self): + return self.locate_dropdown().locator(".menu") + + def test_click_toggle_opens_and_closes_dropdown(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # Click toggle again to close + toggle.click() + expect(menu).not_to_be_visible() + + def test_outside_click_closes_dropdown(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # Click outside the dropdown (on the page body) + self.page.locator("main").click() + expect(menu).not_to_be_visible() + + def test_escape_closes_dropdown_and_restores_focus(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # Press Escape + self.page.keyboard.press("Escape") + + # Menu should be closed + expect(menu).not_to_be_visible() + + # Focus should be back on toggle + expect(toggle).to_be_focused() + + def test_focus_out_closes_dropdown(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # Shift+Tab to move focus out of the dropdown + self.page.keyboard.press("Shift+Tab") + + # Menu should be closed after focus leaves + expect(menu).not_to_be_visible() + + def test_aria_expanded_attribute(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Initially aria-expanded should be false + expect(toggle).to_have_attribute("aria-expanded", "false") + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # aria-expanded should be true + expect(toggle).to_have_attribute("aria-expanded", "true") + + # Close dropdown + toggle.click() + expect(menu).not_to_be_visible() + + # aria-expanded should be false again + expect(toggle).to_have_attribute("aria-expanded", "false") + + def test_can_click_menu_item(self): + with sync_playwright() as p: + self.open(reverse("linkding:bookmarks.index"), p) + + toggle = self.locate_dropdown_toggle() + menu = self.locate_dropdown_menu() + + # Open dropdown + toggle.click() + expect(menu).to_be_visible() + + # Click on "Archived" menu item + menu.get_by_text("Archived", exact=True).click() + + # Should navigate to archived page + expect(self.page).to_have_url( + self.live_server_url + reverse("linkding:bookmarks.archived") + ) diff --git a/bookmarks/tests_e2e/e2e_test_filter_drawer.py b/bookmarks/tests_e2e/e2e_test_filter_drawer.py index cbd32dd..51a4e7f 100644 --- a/bookmarks/tests_e2e/e2e_test_filter_drawer.py +++ b/bookmarks/tests_e2e/e2e_test_filter_drawer.py @@ -20,7 +20,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase): drawer_trigger.click() # verify drawer is visible - drawer = page.locator(".modal.drawer.filter-drawer") + drawer = page.locator("ld-filter-drawer") expect(drawer).to_be_visible() expect(drawer.locator("h2")).to_have_text("Filters") @@ -51,7 +51,7 @@ class FilterDrawerE2ETestCase(LinkdingE2ETestCase): drawer_trigger.click() # verify tags are displayed - drawer = page.locator(".modal.drawer.filter-drawer") + drawer = page.locator("ld-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() diff --git a/bookmarks/tests_e2e/e2e_test_new_bookmark_form.py b/bookmarks/tests_e2e/e2e_test_new_bookmark_form.py index 569056c..c95d3a4 100644 --- a/bookmarks/tests_e2e/e2e_test_new_bookmark_form.py +++ b/bookmarks/tests_e2e/e2e_test_new_bookmark_form.py @@ -174,10 +174,10 @@ class BookmarkFormE2ETestCase(LinkdingE2ETestCase): page = self.open(reverse("linkding:bookmarks.new"), p) title_field = page.get_by_label("Title") - title_clear_button = page.locator("[ld-clear-button][data-for='id_title']") + title_clear_button = page.locator("ld-clear-button[data-for='id_title']") description_field = page.get_by_label("Description") description_clear_button = page.locator( - "[ld-clear-button][data-for='id_description']" + "ld-clear-button[data-for='id_description']" ) # Initially, clear buttons should be hidden because fields are empty diff --git a/bookmarks/tests_e2e/helpers.py b/bookmarks/tests_e2e/helpers.py index 66db8cc..ae5c89a 100644 --- a/bookmarks/tests_e2e/helpers.py +++ b/bookmarks/tests_e2e/helpers.py @@ -43,18 +43,18 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): self.num_loads = 0 def locate_bookmark_list(self): - return self.page.locator("ul[ld-bookmark-list]") + return self.page.locator("ul.bookmark-list") def locate_bookmark(self, title: str): - bookmark_tags = self.page.locator("li[ld-bookmark-item]") + bookmark_tags = self.page.locator("ul.bookmark-list > li") return bookmark_tags.filter(has_text=title) def count_bookmarks(self): - bookmark_tags = self.page.locator("li[ld-bookmark-item]") + bookmark_tags = self.page.locator("ul.bookmark-list > li") return bookmark_tags.count() def locate_details_modal(self): - return self.page.locator(".modal.bookmark-details") + return self.page.locator("ld-details-modal") def open_details_modal(self, bookmark): details_button = self.locate_bookmark(bookmark.title).get_by_text("View") @@ -94,4 +94,4 @@ class LinkdingE2ETestCase(LiveServerTestCase, BookmarkFactoryMixin): 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") + return self.page.locator("ld-confirm-dropdown")