Convert behaviors to web components

This commit is contained in:
Sascha Ißbrücker
2025-12-31 14:37:09 +01:00
parent ee1cf6596b
commit 4fed5de7b3
81 changed files with 1598 additions and 1638 deletions

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 = `
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="btn btn-noborder close" aria-label="Close dialog">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" stroke-width="2"
stroke="currentColor" fill="none" stroke-linecap="round" stroke-linejoin="round">
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`;
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);

View File

@@ -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);

View File

@@ -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);

View File

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

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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`
<div
class="menu with-arrow"
role="alertdialog"
aria-modal="true"
aria-labelledby=${this.confirmId}
>
<span id=${this.confirmId} style="font-weight: bold;">
${questionText}
</span>
<button type="button" class="btn" @click=${this.close}>Cancel</button>
<button type="submit" class="btn btn-error" @click=${this.confirm}>
Confirm
</button>
<div class="menu-arrow"></div>
</div>
`;
}
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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`
<div class="modal-overlay"></div>
<div class="modal-container" role="dialog" aria-modal="true">
<div class="modal-header">
<h2>Filters</h2>
<button class="btn btn-noborder close" aria-label="Close dialog">
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
fill="none"
stroke-linecap="round"
stroke-linejoin="round"
>
<path stroke="none" d="M0 0h24v24H0z" fill="none"></path>
<path d="M18 6l-12 12"></path>
<path d="M6 6l12 12"></path>
</svg>
</button>
</div>
<div class="modal-body">
<div class="content"></div>
</div>
</div>
`,
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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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 {
<input
type="search"
class="form-input"
name="${this.name}"
placeholder="${this.placeholder}"
name="${this.inputName}"
placeholder="${this.inputPlaceholder}"
autocomplete="off"
.value="${this.value}"
.value="${this.inputValue}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}

View File

@@ -1,15 +1,16 @@
import { html, LitElement } from "lit";
import { PositionController } from "../behaviors/position-controller.js";
import { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
import { html, nothing } from "lit";
import { TurboLitElement } from "../utils/element.js";
import { getCurrentWord, getCurrentWordBounds } from "../utils/input.js";
import { PositionController } from "../utils/position-controller.js";
import { cache } from "../utils/tag-cache.js";
export class TagAutocomplete extends LitElement {
export class TagAutocomplete extends TurboLitElement {
static properties = {
id: { type: String },
name: { type: String },
value: { type: String },
placeholder: { type: String },
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
inputId: { type: String, attribute: "input-id" },
inputName: { type: String, attribute: "input-name" },
inputValue: { type: String, attribute: "input-value" },
inputPlaceholder: { type: String, attribute: "input-placeholder" },
inputAriaDescribedBy: { type: String, attribute: "input-aria-describedby" },
variant: { type: String },
isFocus: { state: true },
isOpen: { state: true },
@@ -19,11 +20,11 @@ export class TagAutocomplete extends LitElement {
constructor() {
super();
this.id = "";
this.name = "";
this.value = "";
this.placeholder = "";
this.ariaDescribedBy = "";
this.inputId = "";
this.inputName = "";
this.inputValue = "";
this.inputPlaceholder = "";
this.inputAriaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
@@ -33,10 +34,6 @@ export class TagAutocomplete extends LitElement {
this.suggestionList = null;
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu");
@@ -159,15 +156,15 @@ export class TagAutocomplete extends LitElement {
>
<!-- autocomplete real input box -->
<input
id="${this.id}"
name="${this.name}"
.value="${this.value || ""}"
placeholder="${this.placeholder || " "}"
id="${this.inputId || nothing}"
name="${this.inputName || nothing}"
.value="${this.inputValue || ""}"
placeholder="${this.inputPlaceholder || " "}"
class="form-input"
type="text"
autocomplete="off"
autocapitalize="off"
aria-describedby="${this.ariaDescribedBy}"
aria-describedby="${this.inputAriaDescribedBy || nothing}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}

View File

@@ -0,0 +1,36 @@
class UploadButton extends HTMLElement {
connectedCallback() {
this.onClick = this.onClick.bind(this);
this.onChange = this.onChange.bind(this);
requestAnimationFrame(() => {
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);

View File

@@ -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";

View File

@@ -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");
}
});

View File

@@ -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 = "";
}
}
}

View File

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

View File

@@ -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);