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:
Markus Unterwaditzer 2026-06-27 23:30:59 +02:00 committed by Gusted
commit d464d625d8
8 changed files with 276 additions and 96 deletions

33
package-lock.json generated
View file

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

View file

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

View file

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

View file

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

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

View file

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

View file

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

View 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;
}