mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-27 22:43:15 +08:00
Refactor dropdowns to use fixed positioning
This commit is contained in:
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
71
bookmarks/frontend/behaviors/position-controller.js
Normal file
71
bookmarks/frontend/behaviors/position-controller.js
Normal 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`;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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() {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
box-sizing: border-box;
|
||||
gap: var(--unit-2);
|
||||
padding: var(--unit-2);
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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-aligned .menu-arrow {
|
||||
top: auto;
|
||||
bottom: 0;
|
||||
rotate: 225deg;
|
||||
translate: -50% 50%;
|
||||
}
|
||||
translate: 0 50%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
26
package-lock.json
generated
26
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user