diff --git a/bookmarks/frontend/behaviors/bulk-edit.js b/bookmarks/frontend/behaviors/bulk-edit.js index e5b5578..9d3bb15 100644 --- a/bookmarks/frontend/behaviors/bulk-edit.js +++ b/bookmarks/frontend/behaviors/bulk-edit.js @@ -72,10 +72,7 @@ class BulkEdit extends Behavior { onToggleActive() { this.active = !this.active; if (this.active) { - this.element.classList.add("active", "activating"); - setTimeout(() => { - this.element.classList.remove("activating"); - }, 500); + this.element.classList.add("active"); } else { this.element.classList.remove("active"); } diff --git a/bookmarks/frontend/behaviors/confirm-button.js b/bookmarks/frontend/behaviors/confirm-button.js index fce8db1..a457896 100644 --- a/bookmarks/frontend/behaviors/confirm-button.js +++ b/bookmarks/frontend/behaviors/confirm-button.js @@ -1,5 +1,6 @@ import { Behavior, registerBehavior } from "./index"; import { FocusTrapController, isKeyboardActive } from "./focus-utils"; +import { PositionController } from "./position-controller"; let confirmId = 0; @@ -74,7 +75,13 @@ class ConfirmButtonBehavior extends Behavior { dropdown.append(menu); document.body.append(dropdown); - this.positionController = new AnchorPositionController(this.element, menu); + 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; @@ -95,7 +102,7 @@ class ConfirmButtonBehavior extends Behavior { close() { if (!this.opened) return; - this.positionController.destroy(); + this.positionController.disable(); this.focusTrap.destroy(); this.dropdown.remove(); this.element.focus({ focusVisible: isKeyboardActive() }); @@ -103,71 +110,4 @@ class ConfirmButtonBehavior extends Behavior { } } -class AnchorPositionController { - constructor(anchor, overlay) { - this.anchor = anchor; - this.overlay = overlay; - - this.handleScroll = this.handleScroll.bind(this); - window.addEventListener("scroll", this.handleScroll, { capture: true }); - - this.updatePosition(); - } - - handleScroll() { - if (this.debounce) { - return; - } - - this.debounce = true; - - requestAnimationFrame(() => { - this.updatePosition(); - this.debounce = false; - }); - } - - updatePosition() { - const anchorRect = this.anchor.getBoundingClientRect(); - const overlayRect = this.overlay.getBoundingClientRect(); - const bufferX = 10; - const bufferY = 30; - - let left = anchorRect.left - (overlayRect.width - anchorRect.width) / 2; - const initialLeft = left; - const overflowLeft = left < bufferX; - const overflowRight = - left + overlayRect.width > window.innerWidth - bufferX; - - if (overflowLeft) { - left = bufferX; - } else if (overflowRight) { - left = window.innerWidth - overlayRect.width - bufferX; - } - - const delta = initialLeft - left; - this.overlay.style.setProperty("--arrow-offset", `${delta}px`); - - let top = anchorRect.bottom; - const overflowBottom = - top + overlayRect.height > window.innerHeight - bufferY; - - if (overflowBottom) { - top = anchorRect.top - overlayRect.height; - this.overlay.classList.remove("top-aligned"); - this.overlay.classList.add("bottom-aligned"); - } else { - this.overlay.classList.remove("bottom-aligned"); - this.overlay.classList.add("top-aligned"); - } - - this.overlay.style.left = `${left}px`; - this.overlay.style.top = `${top}px`; - } - - destroy() { - window.removeEventListener("scroll", this.handleScroll, { capture: true }); - } -} - registerBehavior("ld-confirm-button", ConfirmButtonBehavior); diff --git a/bookmarks/frontend/behaviors/position-controller.js b/bookmarks/frontend/behaviors/position-controller.js new file mode 100644 index 0000000..45abf9d --- /dev/null +++ b/bookmarks/frontend/behaviors/position-controller.js @@ -0,0 +1,71 @@ +import { + arrow, + autoUpdate, + computePosition, + flip, + offset, + shift, +} from "@floating-ui/dom"; + +export class PositionController { + constructor(options) { + this.anchor = options.anchor; + this.overlay = options.overlay; + this.arrow = options.arrow; + this.placement = options.placement || "bottom"; + this.offset = options.offset; + this.autoWidth = options.autoWidth || false; + this.autoUpdateCleanup = null; + } + + enable() { + if (!this.autoUpdateCleanup) { + this.autoUpdateCleanup = autoUpdate(this.anchor, this.overlay, () => + this.updatePosition(), + ); + } + } + + disable() { + if (this.autoUpdateCleanup) { + this.autoUpdateCleanup(); + this.autoUpdateCleanup = null; + } + } + + updatePosition() { + const middleware = [flip(), shift()]; + if (this.arrow) { + middleware.push(arrow({ element: this.arrow })); + } + if (this.offset) { + middleware.push(offset(this.offset)); + } + computePosition(this.anchor, this.overlay, { + placement: this.placement, + strategy: "fixed", + middleware, + }).then(({ x, y, placement, middlewareData }) => { + Object.assign(this.overlay.style, { + left: `${x}px`, + top: `${y}px`, + }); + + this.overlay.classList.remove("top-aligned", "bottom-aligned"); + this.overlay.classList.add(`${placement}-aligned`); + + if (this.arrow) { + const { x, y } = middlewareData.arrow; + Object.assign(this.arrow.style, { + left: x != null ? `${x}px` : "", + top: y != null ? `${y}px` : "", + }); + } + }); + + if (this.autoWidth) { + const width = this.anchor.offsetWidth; + this.overlay.style.width = `${width}px`; + } + } +} diff --git a/bookmarks/frontend/components/SearchAutocomplete.js b/bookmarks/frontend/components/SearchAutocomplete.js index 4927bb5..9dffc0f 100644 --- a/bookmarks/frontend/components/SearchAutocomplete.js +++ b/bookmarks/frontend/components/SearchAutocomplete.js @@ -1,4 +1,5 @@ import { LitElement, html } from "lit"; +import { PositionController } from "../behaviors/position-controller"; import { SearchHistory } from "./SearchHistory.js"; import { api } from "../api.js"; import { cache } from "../cache.js"; @@ -41,6 +42,7 @@ export class SearchAutocomplete extends LitElement { }; this.selectedIndex = undefined; this.input = null; + this.menu = null; this.searchHistory = new SearchHistory(); this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions()); } @@ -52,9 +54,21 @@ export class SearchAutocomplete extends LitElement { firstUpdated() { this.style.setProperty("--menu-max-height", "400px"); this.input = this.querySelector("input"); + this.menu = this.querySelector(".menu"); // Track current search query after loading the page this.searchHistory.pushCurrent(); this.updateSuggestions(); + this.positionController = new PositionController({ + anchor: this.input, + overlay: this.menu, + autoWidth: true, + placement: "bottom-start", + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.close(); } handleFocus() { @@ -105,12 +119,14 @@ export class SearchAutocomplete extends LitElement { open() { this.isOpen = true; + this.positionController.enable(); } close() { this.isOpen = false; this.updateSuggestions(); this.selectedIndex = undefined; + this.positionController.disable(); } hasSuggestions() { diff --git a/bookmarks/frontend/components/TagAutocomplete.js b/bookmarks/frontend/components/TagAutocomplete.js index 8d67993..c00c8b2 100644 --- a/bookmarks/frontend/components/TagAutocomplete.js +++ b/bookmarks/frontend/components/TagAutocomplete.js @@ -1,4 +1,5 @@ -import { LitElement, html } from "lit"; +import { html, LitElement } from "lit"; +import { PositionController } from "../behaviors/position-controller.js"; import { cache } from "../cache.js"; import { getCurrentWord, getCurrentWordBounds } from "../util.js"; @@ -39,6 +40,17 @@ export class TagAutocomplete extends LitElement { firstUpdated() { this.input = this.querySelector("input"); this.suggestionList = this.querySelector(".menu"); + this.positionController = new PositionController({ + anchor: this.input, + overlay: this.suggestionList, + autoWidth: true, + placement: "bottom-start", + }); + } + + disconnectedCallback() { + super.disconnectedCallback(); + this.close(); } handleFocus() { @@ -92,12 +104,14 @@ export class TagAutocomplete extends LitElement { open() { this.isOpen = true; this.selectedIndex = 0; + this.positionController.enable(); } close() { this.isOpen = false; this.suggestions = []; this.selectedIndex = 0; + this.positionController.disable(); } complete(suggestion) { diff --git a/bookmarks/styles/bookmark-page.css b/bookmarks/styles/bookmark-page.css index f3b5e69..db069c8 100644 --- a/bookmarks/styles/bookmark-page.css +++ b/bookmarks/styles/bookmark-page.css @@ -478,12 +478,6 @@ ul.bookmark-list { border-bottom-color: transparent; } - /* remove overflow after opening animation, otherwise tag autocomplete overlay gets cut off */ - - &.active:not(.activating) .bulk-edit-bar { - overflow: visible; - } - /* make sticky pagination expand to cover checkboxes to the left */ &.active .bookmark-pagination.sticky:before { diff --git a/bookmarks/styles/components.css b/bookmarks/styles/components.css index c589905..82a6554 100644 --- a/bookmarks/styles/components.css +++ b/bookmarks/styles/components.css @@ -42,6 +42,7 @@ box-sizing: border-box; gap: var(--unit-2); padding: var(--unit-2); + transform: none; } } diff --git a/bookmarks/styles/theme/autocomplete.css b/bookmarks/styles/theme/autocomplete.css index 7f72504..2ce7ea0 100644 --- a/bookmarks/styles/theme/autocomplete.css +++ b/bookmarks/styles/theme/autocomplete.css @@ -1,7 +1,5 @@ /* Autocomplete */ .form-autocomplete { - position: relative; - & .form-autocomplete-input { box-sizing: border-box; align-content: flex-start; @@ -53,10 +51,7 @@ & .menu { display: none; - left: 0; - position: absolute; - top: 100%; - width: 100%; + position: fixed; max-height: var(--menu-max-height, 200px); overflow: auto; diff --git a/bookmarks/styles/theme/menus.css b/bookmarks/styles/theme/menus.css index 4b0002f..55bc3f6 100644 --- a/bookmarks/styles/theme/menus.css +++ b/bookmarks/styles/theme/menus.css @@ -91,39 +91,25 @@ &.with-arrow { overflow: visible; --arrow-size: 16px; - --arrow-offset: 0px; .menu-arrow { display: block; position: absolute; - inset-inline-start: calc(50% + var(--arrow-offset)); top: 0; width: var(--arrow-size); height: var(--arrow-size); - translate: -50% -50%; + translate: 0 -50%; rotate: 45deg; background: inherit; border: inherit; clip-path: polygon(0 0, 0 100%, 100% 0); } - &.top-aligned { - transform: translateY( - calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) - ); - } - - &.bottom-aligned { - transform: translateY( - calc(calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) * -1) - ); - - .menu-arrow { - top: auto; - bottom: 0; - rotate: 225deg; - translate: -50% 50%; - } + &.top-aligned .menu-arrow { + top: auto; + bottom: 0; + rotate: 225deg; + translate: 0 50%; } } } diff --git a/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py b/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py index b35e083..874020d 100644 --- a/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py +++ b/bookmarks/tests_e2e/e2e_test_a11y_navigation_focus.py @@ -22,10 +22,10 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase): # Bookmark form views should focus the URL input page.goto(self.live_server_url + reverse("linkding:bookmarks.new")) + page.wait_for_timeout(timeout=1000) focused_tag = page.evaluate( "document.activeElement?.tagName + '|' + document.activeElement?.name" ) - page.wait_for_timeout(timeout=1000) self.assertEqual("INPUT|url", focused_tag) def test_page_navigation_focus(self): diff --git a/package-lock.json b/package-lock.json index 4602281..6c49e97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "linkding", "dependencies": { + "@floating-ui/dom": "^1.7.4", "@hotwired/turbo": "^8.0.6", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4", @@ -66,6 +67,31 @@ "postcss-selector-parser": "^7.0.0" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.3", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.3.tgz", + "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz", + "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.3", + "@floating-ui/utils": "^0.2.10" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.10", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", + "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", + "license": "MIT" + }, "node_modules/@hotwired/turbo": { "version": "8.0.13", "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.13.tgz", diff --git a/package.json b/package.json index ac6b931..f42cf28 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "dev": "rollup -c -w" }, "dependencies": { + "@floating-ui/dom": "^1.7.4", "@hotwired/turbo": "^8.0.6", "@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-terser": "^0.4.4",