mirror of
https://github.com/sissbruecker/linkding.git
synced 2026-02-28 06:53:12 +08:00
Refactor dropdowns to use fixed positioning
This commit is contained in:
@@ -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");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
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 { 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() {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
26
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user