Replace Svelte components with Lit elements (#1174)

This commit is contained in:
Sascha Ißbrücker
2025-08-24 12:28:15 +02:00
committed by GitHub
parent d519cb74eb
commit d873342105
11 changed files with 613 additions and 668 deletions

View File

@@ -1,6 +1,5 @@
import { mount } from "svelte";
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import SearchAutoCompleteComponent from "../components/SearchAutoComplete.svelte"; import "../components/SearchAutocomplete.js";
class SearchAutocomplete extends Behavior { class SearchAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -11,26 +10,20 @@ class SearchAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-search-autocomplete");
autocomplete.name = "q";
mount(SearchAutoCompleteComponent, { autocomplete.placeholder = input.getAttribute("placeholder") || "";
target: container, autocomplete.value = input.value;
props: { autocomplete.linkTarget = input.dataset.linkTarget || "_blank";
name: "q", autocomplete.mode = input.dataset.mode || "";
placeholder: input.getAttribute("placeholder") || "", autocomplete.search = {
value: input.value, user: input.dataset.user,
linkTarget: input.dataset.linkTarget, shared: input.dataset.shared,
mode: input.dataset.mode, unread: input.dataset.unread,
search: { };
user: input.dataset.user,
shared: input.dataset.shared,
unread: input.dataset.unread,
},
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

View File

@@ -1,6 +1,5 @@
import { Behavior, registerBehavior } from "./index"; import { Behavior, registerBehavior } from "./index";
import TagAutoCompleteComponent from "../components/TagAutocomplete.svelte"; import "../components/TagAutocomplete.js";
import { mount } from "svelte";
class TagAutocomplete extends Behavior { class TagAutocomplete extends Behavior {
constructor(element) { constructor(element) {
@@ -11,22 +10,16 @@ class TagAutocomplete extends Behavior {
return; return;
} }
const container = document.createElement("div"); const autocomplete = document.createElement("ld-tag-autocomplete");
autocomplete.id = input.id;
mount(TagAutoCompleteComponent, { autocomplete.name = input.name;
target: container, autocomplete.value = input.value;
props: { autocomplete.placeholder = input.getAttribute("placeholder") || "";
id: input.id, autocomplete.ariaDescribedBy = input.getAttribute("aria-describedby") || "";
name: input.name, autocomplete.variant = input.getAttribute("variant") || "default";
value: input.value,
placeholder: input.getAttribute("placeholder") || "",
ariaDescribedBy: input.getAttribute("aria-describedby") || "",
variant: input.getAttribute("variant"),
},
});
this.input = input; this.input = input;
this.autocomplete = container.firstElementChild; this.autocomplete = autocomplete;
input.replaceWith(this.autocomplete); input.replaceWith(this.autocomplete);
} }

View File

@@ -1,264 +0,0 @@
<script>
import {SearchHistory} from "./SearchHistory";
import {api} from "../api";
import {cache} from "../cache";
import {clampText, debounce, getCurrentWord, getCurrentWordBounds, preventDefault} from "../util";
const searchHistory = new SearchHistory()
let {
name,
placeholder,
value = $bindable(),
mode = '',
search,
linkTarget = '_blank'
} = $props();
let isFocus = $state(false);
let isOpen = $state(false);
let suggestions = $state([])
let selectedIndex = $state(undefined);
let input = $state(null);
// Track current search query after loading the page
searchHistory.pushCurrent()
updateSuggestions()
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
function handleInput(e) {
value = e.target.value
debouncedLoadSuggestions()
}
function handleKeyDown(e) {
// Enter
if (isOpen && selectedIndex !== undefined && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions.total[selectedIndex];
if (suggestion) completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!isOpen) {
loadSuggestions()
} else {
updateSelection(1);
}
e.preventDefault();
}
}
function open() {
isOpen = true;
}
function close() {
isOpen = false;
updateSuggestions()
selectedIndex = undefined
}
function hasSuggestions() {
return suggestions.total.length > 0
}
async function loadSuggestions() {
let suggestionIndex = 0
function nextIndex() {
return suggestionIndex++
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = []
const currentWord = getCurrentWord(input)
if (currentWord && currentWord.length > 1 && currentWord[0] === '#') {
const searchTag = currentWord.substring(1, currentWord.length)
tagSuggestions = (tags || []).filter(tag => tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0)
.slice(0, 5)
.map(tag => ({
type: 'tag',
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name
}))
}
// Recent search suggestions
const recentSearches = searchHistory.getRecentSearches(value, 5).map(value => ({
type: 'search',
index: nextIndex(),
label: value,
value
}))
// Bookmark suggestions
let bookmarks = []
if (value && value.length >= 3) {
const path = mode ? `/${mode}` : ''
const suggestionSearch = {
...search,
q: value
}
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {limit: 5, offset: 0, path})
bookmarks = fetchedBookmarks.map(bookmark => {
const fullLabel = bookmark.title || bookmark.url
const label = clampText(fullLabel, 60)
return {
type: 'bookmark',
index: nextIndex(),
label,
bookmark
}
})
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions)
if (hasSuggestions()) {
open()
} else {
close()
}
}
const debouncedLoadSuggestions = debounce(loadSuggestions)
function updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || []
bookmarks = bookmarks || []
tagSuggestions = tagSuggestions || []
suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [
...tagSuggestions,
...recentSearches,
...bookmarks,
]
}
}
function completeSuggestion(suggestion) {
if (suggestion.type === 'search') {
value = suggestion.value
close()
}
if (suggestion.type === 'bookmark') {
window.open(suggestion.bookmark.url, linkTarget)
close()
}
if (suggestion.type === 'tag') {
const bounds = getCurrentWordBounds(input);
const inputValue = input.value;
input.value = inputValue.substring(0, bounds.start) + `#${suggestion.tagName} ` + inputValue.substring(bounds.end);
close()
}
}
function updateSelection(dir) {
const length = suggestions.total.length;
if (length === 0) return
if (selectedIndex === undefined) {
selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0)
return
}
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
}
</script>
<div class="form-autocomplete">
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<input type="search" class="form-input" name="{name}" placeholder="{placeholder}" autocomplete="off" value="{value}"
bind:this={input}
oninput={handleInput} onkeydown={handleKeyDown} onfocus={handleFocus} onblur={handleBlur}>
</div>
<ul class="menu" class:open={isOpen}>
{#if suggestions.tags.length > 0}
<li class="menu-item group-item">Tags</li>
{/if}
{#each suggestions.tags as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" onmousedown={preventDefault(() => completeSuggestion(suggestion))}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.recentSearches.length > 0}
<li class="menu-item group-item">Recent Searches</li>
{/if}
{#each suggestions.recentSearches as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" onmousedown={preventDefault(() => completeSuggestion(suggestion))}>
{suggestion.label}
</a>
</li>
{/each}
{#if suggestions.bookmarks.length > 0}
<li class="menu-item group-item">Bookmarks</li>
{/if}
{#each suggestions.bookmarks as suggestion}
<li class="menu-item" class:selected={selectedIndex === suggestion.index}>
<a href="#" onmousedown={preventDefault(() => completeSuggestion(suggestion))}>
{suggestion.label}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 400px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete-input {
padding: 0;
}
.form-autocomplete-input.is-focused {
z-index: 2;
}
</style>

View File

@@ -0,0 +1,304 @@
import { LitElement, html } from "lit";
import { SearchHistory } from "./SearchHistory.js";
import { api } from "../api.js";
import { cache } from "../cache.js";
import {
clampText,
debounce,
getCurrentWord,
getCurrentWordBounds,
} from "../util.js";
export class SearchAutocomplete extends LitElement {
static properties = {
name: { type: String },
placeholder: { type: String },
value: { type: String },
mode: { type: String },
search: { type: Object },
linkTarget: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.name = "";
this.placeholder = "";
this.value = "";
this.mode = "";
this.search = {};
this.linkTarget = "_blank";
this.isFocus = false;
this.isOpen = false;
this.suggestions = {
recentSearches: [],
bookmarks: [],
tags: [],
total: [],
};
this.selectedIndex = undefined;
this.input = null;
this.searchHistory = new SearchHistory();
this.debouncedLoadSuggestions = debounce(() => this.loadSuggestions());
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.style.setProperty("--menu-max-height", "400px");
this.input = this.querySelector("input");
// Track current search query after loading the page
this.searchHistory.pushCurrent();
this.updateSuggestions();
}
handleFocus() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
handleInput(e) {
this.value = e.target.value;
this.debouncedLoadSuggestions();
}
handleKeyDown(e) {
// Enter
if (
this.isOpen &&
this.selectedIndex !== undefined &&
(e.keyCode === 13 || e.keyCode === 9)
) {
const suggestion = this.suggestions.total[this.selectedIndex];
if (suggestion) this.completeSuggestion(suggestion);
e.preventDefault();
}
// Escape
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
// Up arrow
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
// Down arrow
if (e.keyCode === 40) {
if (!this.isOpen) {
this.loadSuggestions();
} else {
this.updateSelection(1);
}
e.preventDefault();
}
}
open() {
this.isOpen = true;
}
close() {
this.isOpen = false;
this.updateSuggestions();
this.selectedIndex = undefined;
}
hasSuggestions() {
return this.suggestions.total.length > 0;
}
async loadSuggestions() {
let suggestionIndex = 0;
function nextIndex() {
return suggestionIndex++;
}
// Tag suggestions
const tags = await cache.getTags();
let tagSuggestions = [];
const currentWord = getCurrentWord(this.input);
if (currentWord && currentWord.length > 1 && currentWord[0] === "#") {
const searchTag = currentWord.substring(1, currentWord.length);
tagSuggestions = (tags || [])
.filter(
(tag) =>
tag.name.toLowerCase().indexOf(searchTag.toLowerCase()) === 0,
)
.slice(0, 5)
.map((tag) => ({
type: "tag",
index: nextIndex(),
label: `#${tag.name}`,
tagName: tag.name,
}));
}
// Recent search suggestions
const recentSearches = this.searchHistory
.getRecentSearches(this.value, 5)
.map((value) => ({
type: "search",
index: nextIndex(),
label: value,
value,
}));
// Bookmark suggestions
let bookmarks = [];
if (this.value && this.value.length >= 3) {
const path = this.mode ? `/${this.mode}` : "";
const suggestionSearch = {
...this.search,
q: this.value,
};
const fetchedBookmarks = await api.listBookmarks(suggestionSearch, {
limit: 5,
offset: 0,
path,
});
bookmarks = fetchedBookmarks.map((bookmark) => {
const fullLabel = bookmark.title || bookmark.url;
const label = clampText(fullLabel, 60);
return {
type: "bookmark",
index: nextIndex(),
label,
bookmark,
};
});
}
this.updateSuggestions(recentSearches, bookmarks, tagSuggestions);
if (this.hasSuggestions()) {
this.open();
} else {
this.close();
}
}
updateSuggestions(recentSearches, bookmarks, tagSuggestions) {
recentSearches = recentSearches || [];
bookmarks = bookmarks || [];
tagSuggestions = tagSuggestions || [];
this.suggestions = {
recentSearches,
bookmarks,
tags: tagSuggestions,
total: [...tagSuggestions, ...recentSearches, ...bookmarks],
};
}
completeSuggestion(suggestion) {
if (suggestion.type === "search") {
this.value = suggestion.value;
this.close();
}
if (suggestion.type === "bookmark") {
window.open(suggestion.bookmark.url, this.linkTarget);
this.close();
}
if (suggestion.type === "tag") {
const bounds = getCurrentWordBounds(this.input);
const inputValue = this.input.value;
this.input.value =
inputValue.substring(0, bounds.start) +
`#${suggestion.tagName} ` +
inputValue.substring(bounds.end);
this.close();
}
}
updateSelection(dir) {
const length = this.suggestions.total.length;
if (length === 0) return;
if (this.selectedIndex === undefined) {
this.selectedIndex = dir > 0 ? 0 : Math.max(length - 1, 0);
return;
}
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
}
renderSuggestions(suggestions, title) {
if (suggestions.length === 0) return "";
return html`
<li class="menu-item group-item">${title}</li>
${suggestions.map(
(suggestion) => html`
<li
class="menu-item ${this.selectedIndex === suggestion.index
? "selected"
: ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.completeSuggestion(suggestion);
}}
>
${suggestion.label}
</a>
</li>
`,
)}
`;
}
render() {
return html`
<div class="form-autocomplete">
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<input
type="search"
class="form-input"
name="${this.name}"
placeholder="${this.placeholder}"
autocomplete="off"
.value="${this.value}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<ul class="menu ${this.isOpen ? "open" : ""}">
${this.renderSuggestions(this.suggestions.tags, "Tags")}
${this.renderSuggestions(
this.suggestions.recentSearches,
"Recent Searches",
)}
${this.renderSuggestions(this.suggestions.bookmarks, "Bookmarks")}
</ul>
</div>
`;
}
}
customElements.define("ld-search-autocomplete", SearchAutocomplete);

View File

@@ -0,0 +1,194 @@
import { LitElement, html } from "lit";
import { cache } from "../cache.js";
import { getCurrentWord, getCurrentWordBounds } from "../util.js";
export class TagAutocomplete extends LitElement {
static properties = {
id: { type: String },
name: { type: String },
value: { type: String },
placeholder: { type: String },
ariaDescribedBy: { type: String, attribute: "aria-described-by" },
variant: { type: String },
isFocus: { state: true },
isOpen: { state: true },
suggestions: { state: true },
selectedIndex: { state: true },
};
constructor() {
super();
this.id = "";
this.name = "";
this.value = "";
this.placeholder = "";
this.ariaDescribedBy = "";
this.variant = "default";
this.isFocus = false;
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
this.input = null;
this.suggestionList = null;
}
createRenderRoot() {
return this;
}
firstUpdated() {
this.input = this.querySelector("input");
this.suggestionList = this.querySelector(".menu");
}
handleFocus() {
this.isFocus = true;
}
handleBlur() {
this.isFocus = false;
this.close();
}
async handleInput(e) {
this.input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(this.input);
this.suggestions = word
? tags.filter(
(tag) => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0,
)
: [];
if (word && this.suggestions.length > 0) {
this.open();
} else {
this.close();
}
}
handleKeyDown(e) {
if (this.isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = this.suggestions[this.selectedIndex];
this.complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
this.close();
e.preventDefault();
}
if (e.keyCode === 38) {
this.updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
this.updateSelection(1);
e.preventDefault();
}
}
open() {
this.isOpen = true;
this.selectedIndex = 0;
}
close() {
this.isOpen = false;
this.suggestions = [];
this.selectedIndex = 0;
}
complete(suggestion) {
const bounds = getCurrentWordBounds(this.input);
const value = this.input.value;
this.input.value =
value.substring(0, bounds.start) +
suggestion.name +
" " +
value.substring(bounds.end);
this.input.dispatchEvent(new CustomEvent("change", { bubbles: true }));
this.close();
}
updateSelection(dir) {
const length = this.suggestions.length;
let newIndex = this.selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
this.selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (this.suggestionList) {
const selectedListItem =
this.suggestionList.querySelector("li.selected");
if (selectedListItem) {
selectedListItem.scrollIntoView({ block: "center" });
}
}
}, 0);
}
render() {
return html`
<div class="form-autocomplete ${this.variant === "small" ? "small" : ""}">
<!-- autocomplete input container -->
<div
class="form-autocomplete-input form-input ${this.isFocus
? "is-focused"
: ""}"
>
<!-- autocomplete real input box -->
<input
id="${this.id}"
name="${this.name}"
.value="${this.value || ""}"
placeholder="${this.placeholder || " "}"
class="form-input"
type="text"
autocomplete="off"
autocapitalize="off"
aria-describedby="${this.ariaDescribedBy}"
@input=${this.handleInput}
@keydown=${this.handleKeyDown}
@focus=${this.handleFocus}
@blur=${this.handleBlur}
/>
</div>
<!-- autocomplete suggestion list -->
<ul
class="menu ${this.isOpen && this.suggestions.length > 0
? "open"
: ""}"
>
<!-- menu list items -->
${this.suggestions.map(
(tag, i) => html`
<li
class="menu-item ${this.selectedIndex === i ? "selected" : ""}"
>
<a
href="#"
@mousedown=${(e) => {
e.preventDefault();
this.complete(tag);
}}
>
${tag.name}
</a>
</li>
`,
)}
</ul>
</div>
`;
}
}
customElements.define("ld-tag-autocomplete", TagAutocomplete);

View File

@@ -1,173 +0,0 @@
<script>
import {cache} from "../cache";
import {getCurrentWord, getCurrentWordBounds, preventDefault} from "../util";
let {
id,
name,
value,
placeholder,
ariaDescribedBy,
variant = 'default'
} = $props();
let isFocus = $state(false);
let isOpen = $state(false);
let input = null;
let suggestionList = $state(null);
let suggestions = $state([]);
let selectedIndex = $state(0);
function handleFocus() {
isFocus = true;
}
function handleBlur() {
isFocus = false;
close();
}
async function handleInput(e) {
input = e.target;
const tags = await cache.getTags();
const word = getCurrentWord(input);
suggestions = word
? tags.filter(tag => tag.name.toLowerCase().indexOf(word.toLowerCase()) === 0)
: [];
if (word && suggestions.length > 0) {
open();
} else {
close();
}
}
function handleKeyDown(e) {
if (isOpen && (e.keyCode === 13 || e.keyCode === 9)) {
const suggestion = suggestions[selectedIndex];
complete(suggestion);
e.preventDefault();
}
if (e.keyCode === 27) {
close();
e.preventDefault();
}
if (e.keyCode === 38) {
updateSelection(-1);
e.preventDefault();
}
if (e.keyCode === 40) {
updateSelection(1);
e.preventDefault();
}
}
function open() {
isOpen = true;
selectedIndex = 0;
}
function close() {
isOpen = false;
suggestions = [];
selectedIndex = 0;
}
function complete(suggestion) {
const bounds = getCurrentWordBounds(input);
const value = input.value;
input.value = value.substring(0, bounds.start) + suggestion.name + ' ' + value.substring(bounds.end);
input.dispatchEvent(new CustomEvent('change', {bubbles: true}));
close();
}
function updateSelection(dir) {
const length = suggestions.length;
let newIndex = selectedIndex + dir;
if (newIndex < 0) newIndex = Math.max(length - 1, 0);
if (newIndex >= length) newIndex = 0;
selectedIndex = newIndex;
// Scroll to selected list item
setTimeout(() => {
if (suggestionList) {
const selectedListItem = suggestionList.querySelector('li.selected');
if (selectedListItem) {
selectedListItem.scrollIntoView({block: 'center'});
}
}
}, 0);
}
</script>
<div class="form-autocomplete" class:small={variant === 'small'}>
<!-- autocomplete input container -->
<div class="form-autocomplete-input form-input" class:is-focused={isFocus}>
<!-- autocomplete real input box -->
<input id="{id}" name="{name}" value="{value ||''}" placeholder="{placeholder || ' '}"
class="form-input" type="text" autocomplete="off" autocapitalize="off"
aria-describedby="{ariaDescribedBy}"
oninput={handleInput} onkeydown={handleKeyDown}
onfocus={handleFocus} onblur={handleBlur}>
</div>
<!-- autocomplete suggestion list -->
<ul class="menu" class:open={isOpen && suggestions.length > 0}
bind:this={suggestionList}>
<!-- menu list items -->
{#each suggestions as tag,i}
<li class="menu-item" class:selected={selectedIndex === i}>
<a href="#" onmousedown={preventDefault(() => complete(tag))}>
{tag.name}
</a>
</li>
{/each}
</ul>
</div>
<style>
.menu {
display: none;
max-height: 200px;
overflow: auto;
}
.menu.open {
display: block;
}
.form-autocomplete .form-autocomplete-input {
box-sizing: border-box;
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
}
.form-autocomplete .form-autocomplete-input input {
width: 100%;
height: 100%;
border: none;
margin: 0;
}
.form-autocomplete.small .form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete.small .form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.form-autocomplete.small .menu .menu-item {
font-size: var(--font-size-sm);
}
</style>

View File

@@ -11,7 +11,5 @@ import "./behaviors/global-shortcuts";
import "./behaviors/search-autocomplete"; import "./behaviors/search-autocomplete";
import "./behaviors/tag-autocomplete"; import "./behaviors/tag-autocomplete";
export { default as TagAutoComplete } from "./components/TagAutocomplete.svelte";
export { default as SearchAutoComplete } from "./components/SearchAutoComplete.svelte";
export { api } from "./api"; export { api } from "./api";
export { cache } from "./cache"; export { cache } from "./cache";

View File

@@ -3,13 +3,14 @@
position: relative; position: relative;
& .form-autocomplete-input { & .form-autocomplete-input {
box-sizing: border-box;
align-content: flex-start; align-content: flex-start;
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
height: auto;
min-height: var(--unit-8);
padding: var(--unit-h);
background: var(--input-bg-color); background: var(--input-bg-color);
height: var(--control-size);
min-height: var(--control-size);
padding: 0;
&.is-focused { &.is-focused {
outline: var(--focus-outline); outline: var(--focus-outline);
@@ -22,10 +23,11 @@
box-shadow: none; box-shadow: none;
display: inline-block; display: inline-block;
flex: 1 0 auto; flex: 1 0 auto;
height: var(--unit-6);
line-height: var(--unit-4); line-height: var(--unit-4);
margin: var(--unit-h); width: 100%;
width: auto; height: 100%;
margin: 0;
border: none;
&:focus { &:focus {
outline: none; outline: none;
@@ -33,11 +35,30 @@
} }
} }
&.small {
.form-autocomplete-input {
height: var(--control-size-sm);
min-height: var(--control-size-sm);
}
.form-autocomplete-input input {
padding: 0.05rem 0.3rem;
font-size: var(--font-size-sm);
}
.menu .menu-item {
font-size: var(--font-size-sm);
}
}
& .menu { & .menu {
display: none;
left: 0; left: 0;
position: absolute; position: absolute;
top: 100%; top: 100%;
width: 100%; width: 100%;
max-height: var(--menu-max-height, 200px);
overflow: auto;
& .menu-item.selected > a, & .menu-item.selected > a,
& .menu-item > a:hover { & .menu-item > a:hover {
@@ -54,4 +75,8 @@
font-weight: bold; font-weight: bold;
} }
} }
& .menu.open {
display: block;
}
} }

223
package-lock.json generated
View File

@@ -14,12 +14,11 @@
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/wasm-node": "^4.13.0", "@rollup/wasm-node": "^4.13.0",
"cssnano": "^7.0.6", "cssnano": "^7.0.6",
"lit": "^3.3.1",
"postcss": "^8.4.45", "postcss": "^8.4.45",
"postcss-cli": "^11.0.0", "postcss-cli": "^11.0.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.0", "postcss-nesting": "^13.0.0"
"rollup-plugin-svelte": "^7.2.2",
"svelte": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.3.3" "prettier": "^3.3.3"
@@ -88,16 +87,6 @@
"@jridgewell/trace-mapping": "^0.3.24" "@jridgewell/trace-mapping": "^0.3.24"
} }
}, },
"node_modules/@jridgewell/remapping": {
"version": "2.3.5",
"resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
"integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.24"
}
},
"node_modules/@jridgewell/resolve-uri": { "node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
@@ -133,6 +122,21 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@jridgewell/sourcemap-codec": "^1.4.14"
} }
}, },
"node_modules/@lit-labs/ssr-dom-shim": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/@lit-labs/ssr-dom-shim/-/ssr-dom-shim-1.4.0.tgz",
"integrity": "sha512-ficsEARKnmmW5njugNYKipTm4SFnbik7CXtoencDZzmzo/dQ+2Q0bgkzJuoJP20Aj0F+izzJjOqsnkd6F/o1bw==",
"license": "BSD-3-Clause"
},
"node_modules/@lit/reactive-element": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@lit/reactive-element/-/reactive-element-2.1.1.tgz",
"integrity": "sha512-N+dm5PAYdQ8e6UlywyyrgI2t++wFGXfHx+dSJ1oBrg6FAxUj40jId++EaRm80MKX5JnlH1sBsyZ5h0bcZKemCg==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.4.0"
}
},
"node_modules/@rollup/plugin-node-resolve": { "node_modules/@rollup/plugin-node-resolve": {
"version": "16.0.1", "version": "16.0.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz", "resolved": "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.1.tgz",
@@ -500,15 +504,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"license": "MIT",
"peerDependencies": {
"acorn": "^8.9.0"
}
},
"node_modules/@types/estree": { "node_modules/@types/estree": {
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -521,6 +516,12 @@
"integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==", "integrity": "sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT"
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
@@ -582,24 +583,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "url": "https://github.com/sponsors/jonschlinkert"
} }
}, },
"node_modules/aria-query": {
"version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"license": "Apache-2.0",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -738,15 +721,6 @@
"node": ">=12" "node": ">=12"
} }
}, },
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/color-convert": { "node_modules/color-convert": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1060,21 +1034,6 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/esm-env": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"license": "MIT"
},
"node_modules/esrap": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15"
}
},
"node_modules/estree-walker": { "node_modules/estree-walker": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz",
@@ -1258,15 +1217,6 @@
"node": ">=0.12.0" "node": ">=0.12.0"
} }
}, },
"node_modules/is-reference": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.6"
}
},
"node_modules/jsonfile": { "node_modules/jsonfile": {
"version": "6.2.0", "version": "6.2.0",
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz",
@@ -1291,11 +1241,36 @@
"url": "https://github.com/sponsors/antonk52" "url": "https://github.com/sponsors/antonk52"
} }
}, },
"node_modules/locate-character": { "node_modules/lit": {
"version": "3.0.0", "version": "3.3.1",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/lit/-/lit-3.3.1.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-Ksr/8L3PTapbdXJCk+EJVB78jDodUMaP54gD24W186zGRARvwrsPfS60wae/SSCTCNZVPd1chXqio1qHQmu4NA==",
"license": "MIT" "license": "BSD-3-Clause",
"dependencies": {
"@lit/reactive-element": "^2.1.0",
"lit-element": "^4.2.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-element": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/lit-element/-/lit-element-4.2.1.tgz",
"integrity": "sha512-WGAWRGzirAgyphK2urmYOV72tlvnxw7YfyLDgQ+OZnM9vQQBQnumQ7jUJe6unEzwGU3ahFOjuz1iz1jjrpCPuw==",
"license": "BSD-3-Clause",
"dependencies": {
"@lit-labs/ssr-dom-shim": "^1.4.0",
"@lit/reactive-element": "^2.1.0",
"lit-html": "^3.3.0"
}
},
"node_modules/lit-html": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/lit-html/-/lit-html-3.3.1.tgz",
"integrity": "sha512-S9hbyDu/vs1qNrithiNyeyv64c9yqiW9l+DBgI18fL+MTvOtWoFR0FWiyq1TxaYef5wNlpEmzlXoBlZEO+WjoA==",
"license": "BSD-3-Clause",
"dependencies": {
"@types/trusted-types": "^2.0.2"
}
}, },
"node_modules/lodash.memoize": { "node_modules/lodash.memoize": {
"version": "4.1.2", "version": "4.1.2",
@@ -1309,15 +1284,6 @@
"integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==", "integrity": "sha512-xfBaXQd9ryd9dlSDvnvI0lvxfLJlYAZzXomUYzLKtUeOQvOP5piqAWuGtrhWeqaXK9hhoM/iyJc5AV+XfsX3HQ==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/magic-string": {
"version": "0.30.18",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.18.tgz",
"integrity": "sha512-yi8swmWbO17qHhwIBNeeZxTceJMeBvWJaId6dyvTSOwTipqeHhMhOrz6513r1sOKnpvQ7zkhlG8tPrpilwTxHQ==",
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdn-data": { "node_modules/mdn-data": {
"version": "2.12.2", "version": "2.12.2",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz",
@@ -2093,20 +2059,12 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/resolve.exports": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/resolve.exports/-/resolve.exports-2.0.3.tgz",
"integrity": "sha512-OcXjMsGdhL4XnbShKpAcSqPMzQoYkYyhbEaeSko47MjRP9NfEQMhZkXL1DoFlt9LWQn4YttrdnV6X2OiyzBi+A==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.48.0", "version": "4.48.0",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.48.0.tgz",
"integrity": "sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==", "integrity": "sha512-BXHRqK1vyt9XVSEHZ9y7xdYtuYbwVod2mLwOMFP7t/Eqoc1pHRlG/WdV2qNeNvZHRQdLedaFycljaYYM96RqJQ==",
"license": "MIT", "license": "MIT",
"optional": true,
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.8" "@types/estree": "1.0.8"
@@ -2142,48 +2100,6 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/rollup-plugin-svelte": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/rollup-plugin-svelte/-/rollup-plugin-svelte-7.2.2.tgz",
"integrity": "sha512-hgnIblTRewaBEVQD6N0Q43o+y6q1TmDRhBjaEzQCi50bs8TXqjc+d1zFZyE8tsfgcfNHZQzclh4RxlFUB85H8Q==",
"license": "MIT",
"dependencies": {
"@rollup/pluginutils": "^4.1.0",
"resolve.exports": "^2.0.0"
},
"engines": {
"node": ">=10"
},
"peerDependencies": {
"rollup": ">=2.0.0",
"svelte": ">=3.5.0"
}
},
"node_modules/rollup-plugin-svelte/node_modules/@rollup/pluginutils": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz",
"integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==",
"license": "MIT",
"dependencies": {
"estree-walker": "^2.0.1",
"picomatch": "^2.2.2"
},
"engines": {
"node": ">= 8.0.0"
}
},
"node_modules/rollup-plugin-svelte/node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/safe-buffer": { "node_modules/safe-buffer": {
"version": "5.2.1", "version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@ -2319,31 +2235,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/svelte": {
"version": "5.38.2",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.38.2.tgz",
"integrity": "sha512-iAcp/oFAWauVSGILdD67n7DiwgLHXZzWZIdzl7araRxu72jUr7PFAo2Iie7gXt0IbnlYvhxCb9GT3ZJUquO3PA==",
"license": "MIT",
"dependencies": {
"@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0",
"@sveltejs/acorn-typescript": "^1.0.5",
"@types/estree": "^1.0.5",
"acorn": "^8.12.1",
"aria-query": "^5.3.1",
"axobject-query": "^4.1.0",
"clsx": "^2.1.1",
"esm-env": "^1.2.1",
"esrap": "^2.1.0",
"is-reference": "^3.0.3",
"locate-character": "^3.0.0",
"magic-string": "^0.30.11",
"zimmerframe": "^1.1.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/svgo": { "node_modules/svgo": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz", "resolved": "https://registry.npmjs.org/svgo/-/svgo-4.0.0.tgz",
@@ -2536,12 +2427,6 @@
"engines": { "engines": {
"node": ">=12" "node": ">=12"
} }
},
"node_modules/zimmerframe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"license": "MIT"
} }
} }
} }

View File

@@ -27,12 +27,11 @@
"@rollup/plugin-terser": "^0.4.4", "@rollup/plugin-terser": "^0.4.4",
"@rollup/wasm-node": "^4.13.0", "@rollup/wasm-node": "^4.13.0",
"cssnano": "^7.0.6", "cssnano": "^7.0.6",
"lit": "^3.3.1",
"postcss": "^8.4.45", "postcss": "^8.4.45",
"postcss-cli": "^11.0.0", "postcss-cli": "^11.0.0",
"postcss-import": "^16.1.0", "postcss-import": "^16.1.0",
"postcss-nesting": "^13.0.0", "postcss-nesting": "^13.0.0"
"rollup-plugin-svelte": "^7.2.2",
"svelte": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"prettier": "^3.3.3" "prettier": "^3.3.3"

View File

@@ -1,27 +1,18 @@
import svelte from "rollup-plugin-svelte"; import resolve from '@rollup/plugin-node-resolve';
import resolve from "@rollup/plugin-node-resolve"; import terser from '@rollup/plugin-terser';
import terser from "@rollup/plugin-terser";
const production = !process.env.ROLLUP_WATCH; const production = !process.env.ROLLUP_WATCH;
export default { export default {
input: "bookmarks/frontend/index.js", input: 'bookmarks/frontend/index.js',
output: { output: {
sourcemap: true, sourcemap: true,
format: "iife", format: 'iife',
name: "linkding", name: 'linkding',
// Generate bundle in static folder to that it is picked up by Django static files finder // Generate bundle in static folder to that it is picked up by Django static files finder
file: "bookmarks/static/bundle.js", file: 'bookmarks/static/bundle.js',
}, },
plugins: [ plugins: [
svelte({
emitCss: false,
compilerOptions: {
// Workaround for rollup-plugin-svelte not setting the compiler option when emitCss is false
css: "injected",
},
}),
// If you have external dependencies installed from // If you have external dependencies installed from
// npm, you'll most likely need these plugins. In // npm, you'll most likely need these plugins. In
// some cases you'll need additional configuration — // some cases you'll need additional configuration —