mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-07-12 20:48:40 +00:00
feat: Move repo topic selector to tom-select (#12736)
This is a proof of concept for replacing Formatic's buggy dropdown with something else. There are a bunch of open gitea issues on github discussing getting rid of Fomantic. Tested in Firefox/Chrome. Attached screenshots are before/after. The one with the input focused in orange, and tighter letter spacing, is after. If this is merged I plan to move the other selectors off of Fomantic. Alternative to https://codeberg.org/forgejo/forgejo/pulls/12731, also fixes #10118 Signed-off-by: Markus Unterwaditzer <markus-tarpit+git@unterwaditzer.net> Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/12736 Reviewed-by: Gusted <gusted@noreply.codeberg.org>
This commit is contained in:
parent
423b6cb589
commit
d464d625d8
8 changed files with 276 additions and 96 deletions
33
package-lock.json
generated
33
package-lock.json
generated
|
|
@ -71,6 +71,7 @@
|
|||
"tinycolor2": "1.6.0",
|
||||
"tippy.js": "6.3.7",
|
||||
"toastify-js": "1.12.0",
|
||||
"tom-select": "2.4.3",
|
||||
"tributejs": "5.1.3",
|
||||
"uint8-to-base64": "0.2.1",
|
||||
"vanilla-colorful": "0.7.2",
|
||||
|
|
@ -2915,6 +2916,21 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@orchidjs/sifter": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.1.0.tgz",
|
||||
"integrity": "sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@orchidjs/unicode-variants": "^1.1.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@orchidjs/unicode-variants": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz",
|
||||
"integrity": "sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
|
|
@ -15448,6 +15464,23 @@
|
|||
"integrity": "sha512-HeMHCO9yLPvP9k0apGSdPUWrUbLnxUKNFzgUoZp1PHCLploIX/4DSQ7V8H25ef+h4iO9n0he7ImfcndnN6nDrQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tom-select": {
|
||||
"version": "2.4.3",
|
||||
"resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.4.3.tgz",
|
||||
"integrity": "sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@orchidjs/sifter": "^1.1.0",
|
||||
"@orchidjs/unicode-variants": "^1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/tom-select"
|
||||
}
|
||||
},
|
||||
"node_modules/toml-eslint-parser": {
|
||||
"version": "0.12.0",
|
||||
"resolved": "https://registry.npmjs.org/toml-eslint-parser/-/toml-eslint-parser-0.12.0.tgz",
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@
|
|||
"tinycolor2": "1.6.0",
|
||||
"tippy.js": "6.3.7",
|
||||
"toastify-js": "1.12.0",
|
||||
"tom-select": "2.4.3",
|
||||
"tributejs": "5.1.3",
|
||||
"uint8-to-base64": "0.2.1",
|
||||
"vanilla-colorful": "0.7.2",
|
||||
|
|
|
|||
|
|
@ -954,7 +954,12 @@ func renderRepoTopics(ctx *context.Context) {
|
|||
ctx.ServerError("models.FindTopics", err)
|
||||
return
|
||||
}
|
||||
ctx.Data["Topics"] = topics
|
||||
|
||||
topicNames := make([]string, 0, len(topics))
|
||||
for _, t := range topics {
|
||||
topicNames = append(topicNames, t.Name)
|
||||
}
|
||||
ctx.Data["Topics"] = topicNames
|
||||
}
|
||||
|
||||
func prepareOpenWithEditorApps(ctx *context.Context) {
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@
|
|||
</div>
|
||||
<div class="repo-topics tw-my-2" id="repo-topics">
|
||||
{{range .Topics}}
|
||||
<a class="repo-topic ui large label" href="{{AppSubUrl}}/explore/repos?q={{.Name}}&topic=1">{{.Name}}</a>
|
||||
<a class="repo-topic ui large label" href="{{AppSubUrl}}/explore/repos?q={{.}}&topic=1">{{.}}</a>
|
||||
{{end}}
|
||||
{{if and .Permission.IsAdmin (not .Repository.IsArchived)}}
|
||||
<button id="manage_topic" class="btn interact-fg tw-text-12">{{ctx.Locale.Tr "repo.topic.manage_topics"}}</button>
|
||||
|
|
@ -22,13 +22,8 @@
|
|||
{{end}}
|
||||
{{if and .Permission.IsAdmin (not .Repository.IsArchived)}}
|
||||
<div class="ui form tw-hidden tw-flex tw-gap-2 tw-my-2" id="topic_edit">
|
||||
<div class="ui fluid multiple search selection dropdown tw-flex-wrap tw-flex-1">
|
||||
<input type="hidden" name="topics" value="{{range $i, $v := .Topics}}{{.Name}}{{if Eval $i "+" 1 "<" (len $.Topics)}},{{end}}{{end}}">
|
||||
{{range .Topics}}
|
||||
{{/* keep the same layout as Fomantic UI generated labels */}}
|
||||
<a class="ui label transition visible tw-cursor-default tw-inline-block" data-value="{{.Name}}">{{.Name}}{{svg "octicon-x" 16 "delete icon"}}</a>
|
||||
{{end}}
|
||||
<div class="text"></div>
|
||||
<div class="tw-flex-1">
|
||||
<input type="text" id="topic-input" name="topics" value="{{StringUtils.Join .Topics ","}}">
|
||||
</div>
|
||||
<div>
|
||||
<button class="ui basic button" id="cancel_topic_edit">{{ctx.Locale.Tr "cancel"}}</button>
|
||||
|
|
|
|||
120
web_src/css/features/tom-select.css
Normal file
120
web_src/css/features/tom-select.css
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
/* Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
* SPDX-License-Identifier: GPL-3.0-or-later */
|
||||
|
||||
.ts-hidden-accessible {
|
||||
border: 0;
|
||||
clip: rect(0 0 0 0);
|
||||
clip-path: inset(50%);
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.ts-wrapper {
|
||||
position: relative;
|
||||
background: var(--color-input-background);
|
||||
border: 1px solid var(--color-input-border);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 4px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.ts-wrapper:hover {
|
||||
border-color: var(--color-input-border-hover);
|
||||
}
|
||||
|
||||
.ts-wrapper.focus {
|
||||
border-color: var(--color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 1px 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control > .item {
|
||||
background: var(--color-label-bg);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 2px 8px;
|
||||
margin: 0;
|
||||
color: var(--color-text);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control > .item .remove {
|
||||
border: none;
|
||||
color: var(--color-text-light);
|
||||
padding: 0;
|
||||
margin-inline-start: 2px;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control > .item .remove:hover {
|
||||
color: var(--color-text);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control > .item.invalid {
|
||||
background: var(--color-error-bg);
|
||||
border-color: var(--color-error-border);
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-control input[type="text"] {
|
||||
color: var(--color-input-text);
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
padding: 0;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
inset-inline: 0;
|
||||
z-index: 100;
|
||||
background: var(--color-body);
|
||||
border: 1px solid var(--color-secondary);
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: 0 4px 8px var(--color-shadow);
|
||||
margin-top: 4px;
|
||||
max-block-size: 300px;
|
||||
overflow-block: auto;
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown .option {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown .option.active,
|
||||
.ts-wrapper .ts-dropdown .option:hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown .option.selected {
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown .create {
|
||||
padding: 8px 12px;
|
||||
color: var(--color-text-light);
|
||||
}
|
||||
|
||||
.ts-wrapper .ts-dropdown .create:hover {
|
||||
background: var(--color-hover);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
|
@ -47,6 +47,7 @@
|
|||
@import "./features/codeeditor.css";
|
||||
@import "./features/projects.css";
|
||||
@import "./features/tribute.css";
|
||||
@import "./features/tom-select.css";
|
||||
@import "./features/console.css";
|
||||
|
||||
@import "./markup/content.css";
|
||||
|
|
|
|||
|
|
@ -1,36 +1,95 @@
|
|||
import $ from 'jquery';
|
||||
import {stripTags} from '../utils.js';
|
||||
import {hideElem, queryElemChildren, showElem} from '../utils/dom.js';
|
||||
import {POST} from '../modules/fetch.js';
|
||||
import {hideElem, showElem} from '../utils/dom.js';
|
||||
import {GET, POST} from '../modules/fetch.js';
|
||||
import {showErrorToast} from '../modules/toast.js';
|
||||
import {createTomSelect} from '../modules/tom-select.ts';
|
||||
|
||||
const {appSubUrl} = window.config;
|
||||
|
||||
export function initRepoTopicBar() {
|
||||
export async function initRepoTopicBar() {
|
||||
const mgrBtn = document.getElementById('manage_topic');
|
||||
if (!mgrBtn) return;
|
||||
|
||||
const editDiv = document.getElementById('topic_edit');
|
||||
const viewDiv = document.getElementById('repo-topics');
|
||||
const topicDropdown = editDiv.querySelector('.ui.dropdown');
|
||||
const inputEl = editDiv.querySelector('input[name=topics]');
|
||||
let lastErrorToast;
|
||||
let tomSelect;
|
||||
|
||||
mgrBtn.addEventListener('click', () => {
|
||||
// Store original topics for cancel functionality
|
||||
const getOriginalTopics = () => {
|
||||
return Array.from(viewDiv.querySelectorAll('.repo-topic'), (el) => el.textContent.trim());
|
||||
};
|
||||
|
||||
mgrBtn.addEventListener('click', async () => {
|
||||
hideElem(viewDiv);
|
||||
showElem(editDiv);
|
||||
topicDropdown.querySelector('input.search').focus();
|
||||
|
||||
// Lazy initialize Tom Select on first click
|
||||
if (!tomSelect) {
|
||||
const originalTopics = getOriginalTopics();
|
||||
|
||||
tomSelect = await createTomSelect(inputEl, {
|
||||
plugins: ['remove_button'],
|
||||
persist: false,
|
||||
createOnBlur: true,
|
||||
loadThrottle: 500,
|
||||
valueField: 'topic_name',
|
||||
labelField: 'topic_name',
|
||||
searchField: 'topic_name',
|
||||
items: originalTopics,
|
||||
render: {
|
||||
no_results: () => null,
|
||||
},
|
||||
async load(query, callback) {
|
||||
if (!query.length) return callback([]);
|
||||
try {
|
||||
const response = await GET(`${appSubUrl}/explore/topics/search?q=${encodeURIComponent(query)}`);
|
||||
const data = await response.json();
|
||||
// Filter out already selected topics
|
||||
const current = this.getValue();
|
||||
const filtered = (data.topics || []).filter((t) => !current.includes(t.topic_name));
|
||||
callback(filtered);
|
||||
} catch {
|
||||
callback([]);
|
||||
}
|
||||
},
|
||||
create(input) {
|
||||
return {topic_name: input.toLowerCase().trim()};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
tomSelect.focus();
|
||||
});
|
||||
|
||||
document.querySelector('#cancel_topic_edit').addEventListener('click', () => {
|
||||
lastErrorToast?.hideToast();
|
||||
hideElem(editDiv);
|
||||
showElem(viewDiv);
|
||||
|
||||
// Reset to original values
|
||||
if (tomSelect) {
|
||||
const originalTopics = getOriginalTopics();
|
||||
tomSelect.clear(true);
|
||||
for (const topic of originalTopics) {
|
||||
tomSelect.addItem(topic, true);
|
||||
}
|
||||
}
|
||||
|
||||
mgrBtn.focus();
|
||||
});
|
||||
|
||||
document.getElementById('save_topic').addEventListener('click', async (e) => {
|
||||
lastErrorToast?.hideToast();
|
||||
const topics = editDiv.querySelector('input[name=topics]').value;
|
||||
|
||||
// Clear any previous invalid state
|
||||
if (tomSelect) {
|
||||
for (const item of tomSelect.wrapper.querySelectorAll('.item.invalid')) {
|
||||
item.classList.remove('invalid');
|
||||
}
|
||||
}
|
||||
|
||||
const topics = inputEl.value;
|
||||
|
||||
const data = new FormData();
|
||||
data.append('topics', topics);
|
||||
|
|
@ -40,7 +99,10 @@ export function initRepoTopicBar() {
|
|||
if (response.ok) {
|
||||
const responseData = await response.json();
|
||||
if (responseData.status === 'ok') {
|
||||
queryElemChildren(viewDiv, '.repo-topic', (el) => el.remove());
|
||||
// Update view with new topics
|
||||
for (const el of viewDiv.querySelectorAll('.repo-topic')) {
|
||||
el.remove();
|
||||
}
|
||||
if (topics.length) {
|
||||
const topicArray = topics.split(',');
|
||||
topicArray.sort();
|
||||
|
|
@ -60,88 +122,17 @@ export function initRepoTopicBar() {
|
|||
// how to test: input topic like " invalid topic " (with spaces), and select it from the list, then "Save"
|
||||
const responseData = await response.json();
|
||||
lastErrorToast = showErrorToast(responseData.message, {duration: 5000});
|
||||
if (responseData.invalidTopics && responseData.invalidTopics.length > 0) {
|
||||
if (responseData.invalidTopics?.length > 0) {
|
||||
const {invalidTopics} = responseData;
|
||||
const topicLabels = queryElemChildren(topicDropdown, 'a.ui.label');
|
||||
for (const [index, value] of topics.split(',').entries()) {
|
||||
// Mark invalid topics in Tom Select
|
||||
const items = tomSelect.wrapper.querySelectorAll('.ts-control .item');
|
||||
const values = topics.split(',');
|
||||
for (const [index, value] of values.entries()) {
|
||||
if (invalidTopics.includes(value)) {
|
||||
topicLabels[index].classList.remove('green');
|
||||
topicLabels[index].classList.add('red');
|
||||
items[index]?.classList.add('invalid');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$(topicDropdown).dropdown({
|
||||
allowAdditions: true,
|
||||
forceSelection: false,
|
||||
fullTextSearch: 'exact',
|
||||
fields: {name: 'description', value: 'data-value'},
|
||||
saveRemoteData: false,
|
||||
label: {
|
||||
transition: 'horizontal flip',
|
||||
duration: 200,
|
||||
variation: false,
|
||||
},
|
||||
apiSettings: {
|
||||
url: `${appSubUrl}/explore/topics/search?q={query}`,
|
||||
throttle: 500,
|
||||
cache: false,
|
||||
onResponse(res) {
|
||||
const formattedResponse = {
|
||||
success: false,
|
||||
results: [],
|
||||
};
|
||||
const query = stripTags(this.urlData.query.trim());
|
||||
let found_query = false;
|
||||
const current_topics = [];
|
||||
for (const el of queryElemChildren(topicDropdown, 'a.ui.label.visible')) {
|
||||
current_topics.push(el.getAttribute('data-value'));
|
||||
}
|
||||
|
||||
if (res.topics) {
|
||||
let found = false;
|
||||
for (let i = 0; i < res.topics.length; i++) {
|
||||
// skip currently added tags
|
||||
if (current_topics.includes(res.topics[i].topic_name)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (res.topics[i].topic_name.toLowerCase() === query.toLowerCase()) {
|
||||
found_query = true;
|
||||
}
|
||||
formattedResponse.results.push({description: res.topics[i].topic_name, 'data-value': res.topics[i].topic_name});
|
||||
found = true;
|
||||
}
|
||||
formattedResponse.success = found;
|
||||
}
|
||||
|
||||
if (query.length > 0 && !found_query) {
|
||||
formattedResponse.success = true;
|
||||
formattedResponse.results.unshift({description: query, 'data-value': query});
|
||||
} else if (query.length > 0 && found_query) {
|
||||
formattedResponse.results.sort((a, b) => {
|
||||
if (a.description.toLowerCase() === query.toLowerCase()) return -1;
|
||||
if (b.description.toLowerCase() === query.toLowerCase()) return 1;
|
||||
if (a.description > b.description) return -1;
|
||||
if (a.description < b.description) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return formattedResponse;
|
||||
},
|
||||
},
|
||||
onLabelCreate(value) {
|
||||
value = value.toLowerCase().trim();
|
||||
this.attr('data-value', value).contents().first().replaceWith(value);
|
||||
return $(this);
|
||||
},
|
||||
onAdd(addedValue, _addedText, $addedChoice) {
|
||||
addedValue = addedValue.toLowerCase().trim();
|
||||
$addedChoice[0].setAttribute('data-value', addedValue);
|
||||
$addedChoice[0].setAttribute('data-text', addedValue);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
34
web_src/js/modules/tom-select.ts
Normal file
34
web_src/js/modules/tom-select.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
// Copyright 2026 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
import type TomSelectType from 'tom-select';
|
||||
import type {TomSettings} from 'tom-select/dist/esm/types/index.ts';
|
||||
|
||||
export async function createTomSelect(el: HTMLInputElement, opts: Partial<TomSettings> = {}): Promise<TomSelectType> {
|
||||
const {default: TomSelect} = await import(/* webpackChunkName: "tom-select" */'tom-select');
|
||||
const ts = new TomSelect(el, {
|
||||
...opts,
|
||||
onItemAdd(...args: unknown[]) {
|
||||
ts.setTextboxValue('');
|
||||
opts.onItemAdd?.apply(this, args);
|
||||
},
|
||||
});
|
||||
|
||||
// Handle comma key to create item immediately
|
||||
ts.control_input.addEventListener('keydown', (e: KeyboardEvent) => {
|
||||
if (e.key === ',') {
|
||||
e.preventDefault();
|
||||
const value = ts.inputValue().trim();
|
||||
if (value) {
|
||||
// Use addItem if option exists, otherwise createItem
|
||||
if (ts.options[value]) {
|
||||
ts.addItem(value);
|
||||
} else {
|
||||
ts.createItem(value);
|
||||
}
|
||||
ts.setTextboxValue('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return ts;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue