Refactor dropdowns to use fixed positioning

This commit is contained in:
Sascha Ißbrücker
2025-12-21 10:22:39 +01:00
parent 74ddf45632
commit 12dd1d8bc6
12 changed files with 148 additions and 107 deletions

View File

@@ -72,10 +72,7 @@ class BulkEdit extends Behavior {
onToggleActive() { onToggleActive() {
this.active = !this.active; this.active = !this.active;
if (this.active) { if (this.active) {
this.element.classList.add("active", "activating"); this.element.classList.add("active");
setTimeout(() => {
this.element.classList.remove("activating");
}, 500);
} else { } else {
this.element.classList.remove("active"); this.element.classList.remove("active");
} }

View File

@@ -1,5 +1,6 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import { FocusTrapController, isKeyboardActive } from "./focus-utils"; import { FocusTrapController, isKeyboardActive } from "./focus-utils";
import { PositionController } from "./position-controller";
let confirmId = 0; let confirmId = 0;
@@ -74,7 +75,13 @@ class ConfirmButtonBehavior extends Behavior {
dropdown.append(menu); dropdown.append(menu);
document.body.append(dropdown); 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.focusTrap = new FocusTrapController(menu);
this.dropdown = dropdown; this.dropdown = dropdown;
this.opened = true; this.opened = true;
@@ -95,7 +102,7 @@ class ConfirmButtonBehavior extends Behavior {
close() { close() {
if (!this.opened) return; if (!this.opened) return;
this.positionController.destroy(); this.positionController.disable();
this.focusTrap.destroy(); this.focusTrap.destroy();
this.dropdown.remove(); this.dropdown.remove();
this.element.focus({ focusVisible: isKeyboardActive() }); 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); registerBehavior("ld-confirm-button", ConfirmButtonBehavior);

View File

@@ -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`;
}
}
}

View File

@@ -1,4 +1,5 @@
import { LitElement, html } from "lit"; import { LitElement, html } from "lit";
import { PositionController } from "../behaviors/position-controller";
import { SearchHistory } from "./SearchHistory.js"; import { SearchHistory } from "./SearchHistory.js";
import { api } from "../api.js"; import { api } from "../api.js";
import { cache } from "../cache.js"; import { cache } from "../cache.js";
@@ -41,6 +42,7 @@ export class SearchAutocomplete extends LitElement {
}; };
this.selectedIndex = undefined; this.selectedIndex = undefined;
this.input = null; this.input = null;
this.menu = null;
this.searchHistory = new SearchHistory(); this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions()); this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
} }
@@ -52,9 +54,21 @@ export class SearchAutocomplete extends LitElement {
firstUpdated() { firstUpdated() {
this.style.setProperty("--menu-max-height", "400px"); this.style.setProperty("--menu-max-height", "400px");
this.input = this.querySelector("input"); this.input = this.querySelector("input");
this.menu = this.querySelector(".menu");
// Track current search query after loading the page // Track current search query after loading the page
this.searchHistory.pushCurrent(); this.searchHistory.pushCurrent();
this.updateSuggestions(); this.updateSuggestions();
this.positionController = new PositionController({
anchor: this.input,
overlay: this.menu,
autoWidth: true,
placement: "bottom-start",
});
}
disconnectedCallback() {
super.disconnectedCallback();
this.close();
} }
handleFocus() { handleFocus() {
@@ -105,12 +119,14 @@ export class SearchAutocomplete extends LitElement {
open() { open() {
this.isOpen = true; this.isOpen = true;
this.positionController.enable();
} }
close() { close() {
this.isOpen = false; this.isOpen = false;
this.updateSuggestions(); this.updateSuggestions();
this.selectedIndex = undefined; this.selectedIndex = undefined;
this.positionController.disable();
} }
hasSuggestions() { hasSuggestions() {

View File

@@ -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 { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js"; import { getCurrentWord, getCurrentWordBounds } from "../util.js";
@@ -39,6 +40,17 @@ export class TagAutocomplete extends LitElement {
firstUpdated() { firstUpdated() {
this.input = this.querySelector("input"); this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu"); 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() { handleFocus() {
@@ -92,12 +104,14 @@ export class TagAutocomplete extends LitElement {
open() { open() {
this.isOpen = true; this.isOpen = true;
this.selectedIndex = 0; this.selectedIndex = 0;
this.positionController.enable();
} }
close() { close() {
this.isOpen = false; this.isOpen = false;
this.suggestions = []; this.suggestions = [];
this.selectedIndex = 0; this.selectedIndex = 0;
this.positionController.disable();
} }
complete(suggestion) { complete(suggestion) {

View File

@@ -478,12 +478,6 @@ ul.bookmark-list {
border-bottom-color: transparent; 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 */ /* make sticky pagination expand to cover checkboxes to the left */
&.active .bookmark-pagination.sticky:before { &.active .bookmark-pagination.sticky:before {

View File

@@ -42,6 +42,7 @@
box-sizing: border-box; box-sizing: border-box;
gap: var(--unit-2); gap: var(--unit-2);
padding: var(--unit-2); padding: var(--unit-2);
transform: none;
} }
} }

View File

@@ -1,7 +1,5 @@
/* Autocomplete */ /* Autocomplete */
.form-autocomplete { .form-autocomplete {
position: relative;
& .form-autocomplete-input { & .form-autocomplete-input {
box-sizing: border-box; box-sizing: border-box;
align-content: flex-start; align-content: flex-start;
@@ -53,10 +51,7 @@
& .menu { & .menu {
display: none; display: none;
left: 0; position: fixed;
position: absolute;
top: 100%;
width: 100%;
max-height: var(--menu-max-height, 200px); max-height: var(--menu-max-height, 200px);
overflow: auto; overflow: auto;

View File

@@ -91,39 +91,25 @@
&.with-arrow { &.with-arrow {
overflow: visible; overflow: visible;
--arrow-size: 16px; --arrow-size: 16px;
--arrow-offset: 0px;
.menu-arrow { .menu-arrow {
display: block; display: block;
position: absolute; position: absolute;
inset-inline-start: calc(50% + var(--arrow-offset));
top: 0; top: 0;
width: var(--arrow-size); width: var(--arrow-size);
height: var(--arrow-size); height: var(--arrow-size);
translate: -50% -50%; translate: 0 -50%;
rotate: 45deg; rotate: 45deg;
background: inherit; background: inherit;
border: inherit; border: inherit;
clip-path: polygon(0 0, 0 100%, 100% 0); clip-path: polygon(0 0, 0 100%, 100% 0);
} }
&.top-aligned { &.top-aligned .menu-arrow {
transform: translateY( top: auto;
calc(calc(var(--arrow-size) / 2) + var(--layout-spacing-sm)) bottom: 0;
); rotate: 225deg;
} translate: 0 50%;
&.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%;
}
} }
} }
} }

View File

@@ -22,10 +22,10 @@ class A11yNavigationFocusTest(LinkdingE2ETestCase):
# Bookmark form views should focus the URL input # Bookmark form views should focus the URL input
page.goto(self.live_server_url + reverse("linkding:bookmarks.new")) page.goto(self.live_server_url + reverse("linkding:bookmarks.new"))
page.wait_for_timeout(timeout=1000)
focused_tag = page.evaluate( focused_tag = page.evaluate(
"document.activeElement?.tagName + '|' + document.activeElement?.name" "document.activeElement?.tagName + '|' + document.activeElement?.name"
) )
page.wait_for_timeout(timeout=1000)
self.assertEqual("INPUT|url", focused_tag) self.assertEqual("INPUT|url", focused_tag)
def test_page_navigation_focus(self): def test_page_navigation_focus(self):

26
package-lock.json generated
View File

@@ -7,6 +7,7 @@
"": { "": {
"name": "linkding", "name": "linkding",
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.4",
"@hotwired/turbo": "^8.0.6", "@hotwired/turbo": "^8.0.6",
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
@@ -66,6 +67,31 @@
"postcss-selector-parser": "^7.0.0" "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": { "node_modules/@hotwired/turbo": {
"version": "8.0.13", "version": "8.0.13",
"resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.13.tgz", "resolved": "https://registry.npmjs.org/@hotwired/turbo/-/turbo-8.0.13.tgz",

View File

@@ -8,6 +8,7 @@
"dev": "rollup -c -w" "dev": "rollup -c -w"
}, },
"dependencies": { "dependencies": {
"@floating-ui/dom": "^1.7.4",
"@hotwired/turbo": "^8.0.6", "@hotwired/turbo": "^8.0.6",
"@rollup/plugin-node-resolve": "^16.0.1", "@rollup/plugin-node-resolve": "^16.0.1",
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",