feat: add tag combobox to make tagging expenses easier

This commit is contained in:
juancwu 2026-02-17 21:13:46 +00:00
commit ef37360da7
9 changed files with 671 additions and 106 deletions

377
assets/js/tagcombobox.js Normal file
View file

@ -0,0 +1,377 @@
(function () {
"use strict";
// --- Chip creation ---
function createChip(name, disabled) {
var chip = document.createElement("div");
chip.setAttribute("data-tagcombobox-chip", name);
chip.className =
"inline-flex items-center gap-2 rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors border-transparent bg-primary text-primary-foreground";
chip.innerHTML =
'<span>' + escapeHTML(name) + '</span>' +
'<button type="button" class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer" data-tagcombobox-remove="' + escapeAttr(name) + '"' + (disabled ? " disabled" : "") + '>' +
'<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"/></svg>' +
'</button>';
return chip;
}
function escapeHTML(str) {
var div = document.createElement("div");
div.textContent = str;
return div.innerHTML;
}
function escapeAttr(str) {
return str.replace(/"/g, "&quot;").replace(/'/g, "&#39;");
}
// --- State helpers ---
function getSelectedTags(container) {
var inputs = container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]');
var tags = [];
for (var i = 0; i < inputs.length; i++) {
tags.push(inputs[i].value.toLowerCase());
}
return tags;
}
function isTagSelected(container, name) {
return getSelectedTags(container).indexOf(name.toLowerCase()) !== -1;
}
function addTag(container, name) {
if (isTagSelected(container, name)) return;
var disabled = container.querySelector("[data-tagcombobox-text-input]")?.hasAttribute("disabled");
var chipsContainer = container.querySelector("[data-tagcombobox-chips]");
var hiddenInputs = container.querySelector("[data-tagcombobox-hidden-inputs]");
var fieldName = container.getAttribute("data-tagcombobox-name");
var formAttr = container.getAttribute("data-tagcombobox-form");
// Add chip
chipsContainer.appendChild(createChip(name, disabled));
// Add hidden input
var input = document.createElement("input");
input.type = "hidden";
input.name = fieldName;
input.value = name;
if (formAttr) input.setAttribute("form", formAttr);
hiddenInputs.appendChild(input);
// Update dropdown checkmark
updateDropdownState(container);
}
function removeTag(container, name) {
var chip = container.querySelector('[data-tagcombobox-chip="' + CSS.escape(name) + '"]');
if (chip) chip.remove();
var hiddenInputs = container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]');
for (var i = 0; i < hiddenInputs.length; i++) {
if (hiddenInputs[i].value.toLowerCase() === name.toLowerCase()) {
hiddenInputs[i].remove();
break;
}
}
updateDropdownState(container);
}
function updateDropdownState(container) {
var items = container.querySelectorAll("[data-tagcombobox-item]");
for (var i = 0; i < items.length; i++) {
var itemName = items[i].getAttribute("data-tagcombobox-item");
var check = items[i].querySelector("[data-tagcombobox-check]");
var selected = isTagSelected(container, itemName);
if (check) {
check.classList.toggle("opacity-100", selected);
check.classList.toggle("opacity-0", !selected);
}
items[i].classList.toggle("font-medium", selected);
}
}
// --- Dropdown ---
function showDropdown(container) {
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
dropdown.classList.remove("hidden");
filterDropdown(container);
}
function hideDropdown(container) {
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
dropdown.classList.add("hidden");
clearHighlight(container);
}
function isDropdownVisible(container) {
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
return !dropdown.classList.contains("hidden");
}
function filterDropdown(container) {
var textInput = container.querySelector("[data-tagcombobox-text-input]");
var query = (textInput.value || "").toLowerCase().trim();
var items = container.querySelectorAll("[data-tagcombobox-item]");
var createOption = container.querySelector("[data-tagcombobox-create]");
var createLabel = container.querySelector("[data-tagcombobox-create-label]");
var hasExactMatch = false;
var visibleCount = 0;
for (var i = 0; i < items.length; i++) {
var itemName = items[i].getAttribute("data-tagcombobox-item");
var matches = query === "" || itemName.toLowerCase().indexOf(query) !== -1;
items[i].style.display = matches ? "" : "none";
if (matches) visibleCount++;
if (itemName.toLowerCase() === query) hasExactMatch = true;
}
// Show "Create" option if typed text doesn't exactly match an existing tag
if (query && !hasExactMatch) {
createLabel.textContent = query;
createOption.style.display = "flex";
createOption.classList.remove("hidden");
visibleCount++;
} else {
createOption.style.display = "none";
createOption.classList.add("hidden");
}
// Show dropdown if items to show
var dropdown = container.querySelector("[data-tagcombobox-dropdown]");
if (visibleCount > 0) {
dropdown.classList.remove("hidden");
}
}
// --- Keyboard navigation ---
function getVisibleItems(container) {
var all = container.querySelectorAll("[data-tagcombobox-item], [data-tagcombobox-create]");
var visible = [];
for (var i = 0; i < all.length; i++) {
if (all[i].style.display !== "none" && !all[i].classList.contains("hidden")) {
visible.push(all[i]);
}
}
return visible;
}
function getHighlightedIndex(container) {
var items = getVisibleItems(container);
for (var i = 0; i < items.length; i++) {
if (items[i].hasAttribute("data-tagcombobox-highlighted")) return i;
}
return -1;
}
function clearHighlight(container) {
var highlighted = container.querySelectorAll("[data-tagcombobox-highlighted]");
for (var i = 0; i < highlighted.length; i++) {
highlighted[i].removeAttribute("data-tagcombobox-highlighted");
highlighted[i].classList.remove("bg-accent", "text-accent-foreground");
}
}
function highlightItem(container, index) {
clearHighlight(container);
var items = getVisibleItems(container);
if (index >= 0 && index < items.length) {
items[index].setAttribute("data-tagcombobox-highlighted", "");
items[index].classList.add("bg-accent", "text-accent-foreground");
items[index].scrollIntoView({ block: "nearest" });
}
}
function selectHighlighted(container) {
var items = getVisibleItems(container);
var index = getHighlightedIndex(container);
if (index === -1 && items.length > 0) index = 0;
if (index === -1) return;
var item = items[index];
if (item.hasAttribute("data-tagcombobox-create")) {
// Create new tag
var label = item.querySelector("[data-tagcombobox-create-label]").textContent;
addTag(container, label);
} else {
var name = item.getAttribute("data-tagcombobox-item");
if (isTagSelected(container, name)) {
removeTag(container, name);
} else {
addTag(container, name);
}
}
var textInput = container.querySelector("[data-tagcombobox-text-input]");
textInput.value = "";
filterDropdown(container);
}
// --- Event listeners ---
// Click on input area -> focus text input
document.addEventListener("click", function (e) {
// Remove button
var removeBtn = e.target.closest("[data-tagcombobox-remove]");
if (removeBtn && !removeBtn.disabled) {
e.preventDefault();
e.stopPropagation();
var container = removeBtn.closest("[data-tagcombobox]");
var name = removeBtn.getAttribute("data-tagcombobox-remove");
removeTag(container, name);
return;
}
// Dropdown item click
var item = e.target.closest("[data-tagcombobox-item]");
if (item) {
e.preventDefault();
var container = item.closest("[data-tagcombobox]");
var name = item.getAttribute("data-tagcombobox-item");
if (isTagSelected(container, name)) {
removeTag(container, name);
} else {
addTag(container, name);
}
var textInput = container.querySelector("[data-tagcombobox-text-input]");
textInput.value = "";
filterDropdown(container);
textInput.focus();
return;
}
// Create option click
var createOpt = e.target.closest("[data-tagcombobox-create]");
if (createOpt) {
e.preventDefault();
var container = createOpt.closest("[data-tagcombobox]");
var label = createOpt.querySelector("[data-tagcombobox-create-label]").textContent;
addTag(container, label);
var textInput = container.querySelector("[data-tagcombobox-text-input]");
textInput.value = "";
filterDropdown(container);
textInput.focus();
return;
}
// Click on input area -> focus
var inputArea = e.target.closest("[data-tagcombobox-input-area]");
if (inputArea) {
var container = inputArea.closest("[data-tagcombobox]");
var textInput = container.querySelector("[data-tagcombobox-text-input]");
if (textInput && !textInput.disabled) {
textInput.focus();
}
return;
}
// Click outside -> close all dropdowns
var allContainers = document.querySelectorAll("[data-tagcombobox]");
for (var i = 0; i < allContainers.length; i++) {
if (!allContainers[i].contains(e.target)) {
hideDropdown(allContainers[i]);
}
}
});
// Focus -> show dropdown
document.addEventListener("focusin", function (e) {
var textInput = e.target.closest("[data-tagcombobox-text-input]");
if (!textInput) return;
var container = textInput.closest("[data-tagcombobox]");
if (container) {
updateDropdownState(container);
showDropdown(container);
}
});
// Input -> filter
document.addEventListener("input", function (e) {
var textInput = e.target.closest("[data-tagcombobox-text-input]");
if (!textInput) return;
var container = textInput.closest("[data-tagcombobox]");
if (container) {
filterDropdown(container);
clearHighlight(container);
}
});
// Keyboard navigation
document.addEventListener("keydown", function (e) {
var textInput = e.target.closest("[data-tagcombobox-text-input]");
if (!textInput) return;
var container = textInput.closest("[data-tagcombobox]");
if (!container) return;
if (e.key === "ArrowDown") {
e.preventDefault();
if (!isDropdownVisible(container)) {
showDropdown(container);
return;
}
var items = getVisibleItems(container);
var idx = getHighlightedIndex(container);
highlightItem(container, Math.min(idx + 1, items.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
var idx = getHighlightedIndex(container);
if (idx > 0) {
highlightItem(container, idx - 1);
}
} else if (e.key === "Enter") {
e.preventDefault();
if (isDropdownVisible(container)) {
var idx = getHighlightedIndex(container);
if (idx >= 0) {
selectHighlighted(container);
} else {
// If text typed but nothing highlighted, try create or select first match
var query = textInput.value.trim();
if (query) {
var createOpt = container.querySelector("[data-tagcombobox-create]");
if (createOpt && createOpt.style.display !== "none" && !createOpt.classList.contains("hidden")) {
addTag(container, query);
textInput.value = "";
filterDropdown(container);
} else {
// Select first visible item
var items = getVisibleItems(container);
if (items.length > 0) {
highlightItem(container, 0);
selectHighlighted(container);
}
}
}
}
}
} else if (e.key === "Escape") {
hideDropdown(container);
} else if (e.key === "Backspace" && textInput.value === "") {
// Remove last chip
var chips = container.querySelectorAll("[data-tagcombobox-chip]");
if (chips.length > 0) {
var lastChip = chips[chips.length - 1];
var name = lastChip.getAttribute("data-tagcombobox-chip");
removeTag(container, name);
}
}
});
// Form reset
document.addEventListener("reset", function (e) {
if (!e.target.matches("form")) return;
e.target.querySelectorAll("[data-tagcombobox]").forEach(function (container) {
container.querySelectorAll("[data-tagcombobox-chip]").forEach(function (chip) {
chip.remove();
});
container.querySelectorAll('[data-tagcombobox-hidden-inputs] input[type="hidden"]').forEach(function (inp) {
inp.remove();
});
var textInput = container.querySelector("[data-tagcombobox-text-input]");
if (textInput) textInput.value = "";
updateDropdownState(container);
hideDropdown(container);
});
});
})();

View file

@ -5,7 +5,6 @@ import (
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"git.juancwu.dev/juancwu/budgit/internal/ctxkeys"
@ -697,7 +696,9 @@ func (h *SpaceHandler) CreateExpense(w http.ResponseWriter, r *http.Request) {
return
}
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, totalAllocated, 1, totalPages))
// Re-fetch tags (may have been auto-created)
refreshedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpenseCreatedResponse(spaceID, expenses, balance, totalAllocated, refreshedTags, 1, totalPages))
// OOB-swap the item selector with fresh data (items may have been deleted/checked)
listsWithItems, err := h.listService.GetListsWithUncheckedItems(spaceID)
@ -832,7 +833,8 @@ func (h *SpaceHandler) UpdateExpense(w http.ResponseWriter, r *http.Request) {
balance -= totalAllocated
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods))
updatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpenseUpdatedResponse(spaceID, expWithTagsAndMethod, balance, totalAllocated, methods, updatedTags))
}
func (h *SpaceHandler) DeleteExpense(w http.ResponseWriter, r *http.Request) {
@ -978,7 +980,8 @@ func (h *SpaceHandler) GetExpensesList(w http.ResponseWriter, r *http.Request) {
}
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, page, totalPages))
paginatedTags, _ := h.tagService.GetTagsForSpace(spaceID)
ui.Render(w, r, pages.ExpensesListContent(spaceID, expenses, methods, paginatedTags, page, totalPages))
}
func (h *SpaceHandler) GetShoppingListItems(w http.ResponseWriter, r *http.Request) {
@ -1779,16 +1782,17 @@ func (h *SpaceHandler) CreateRecurringExpense(w http.ResponseWriter, r *http.Req
}
// Fetch tags/method for the response
spaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
tagsMap, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMap {
if item.ID == re.ID {
ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil))
ui.Render(w, r, recurring.RecurringItem(spaceID, item, nil, spaceTags))
return
}
}
// Fallback: render without tags
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil))
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *re}, nil, spaceTags))
}
func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Request) {
@ -1892,16 +1896,17 @@ func (h *SpaceHandler) UpdateRecurringExpense(w http.ResponseWriter, r *http.Req
}
// Build response with tags/method
updateSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMapResult {
if item.ID == updated.ID {
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, updateSpaceTags))
return
}
}
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, updateSpaceTags))
}
func (h *SpaceHandler) DeleteRecurringExpense(w http.ResponseWriter, r *http.Request) {
@ -1943,20 +1948,63 @@ func (h *SpaceHandler) ToggleRecurringExpense(w http.ResponseWriter, r *http.Req
return
}
toggleSpaceTags, _ := h.tagService.GetTagsForSpace(spaceID)
tagsMapResult, _ := h.recurringService.GetRecurringExpensesWithTagsAndMethodsForSpace(spaceID)
for _, item := range tagsMapResult {
if item.ID == updated.ID {
methods, _ := h.methodService.GetMethodsForSpace(spaceID)
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods))
ui.Render(w, r, recurring.RecurringItem(spaceID, item, methods, toggleSpaceTags))
return
}
}
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil))
ui.Render(w, r, recurring.RecurringItem(spaceID, &model.RecurringExpenseWithTagsAndMethod{RecurringExpense: *updated}, nil, toggleSpaceTags))
}
// --- Budgets ---
// processTagNames normalizes tag names, deduplicates them, and resolves them
// to tag IDs. Tags that don't exist are auto-created.
func (h *SpaceHandler) processTagNames(spaceID string, tagNames []string) ([]string, error) {
existingTags, err := h.tagService.GetTagsForSpace(spaceID)
if err != nil {
return nil, err
}
existingTagsMap := make(map[string]string)
for _, t := range existingTags {
existingTagsMap[t.Name] = t.ID
}
var finalTagIDs []string
processedTags := make(map[string]bool)
for _, rawTagName := range tagNames {
tagName := service.NormalizeTagName(rawTagName)
if tagName == "" {
continue
}
if processedTags[tagName] {
continue
}
if id, exists := existingTagsMap[tagName]; exists {
finalTagIDs = append(finalTagIDs, id)
} else {
newTag, err := h.tagService.CreateTag(spaceID, tagName, nil)
if err != nil {
slog.Error("failed to create new tag", "error", err, "tag_name", tagName)
continue
}
finalTagIDs = append(finalTagIDs, newTag.ID)
existingTagsMap[tagName] = newTag.ID
}
processedTags[tagName] = true
}
return finalTagIDs, nil
}
func (h *SpaceHandler) getBudgetForSpace(w http.ResponseWriter, spaceID, budgetID string) *model.Budget {
budget, err := h.budgetService.GetBudget(budgetID)
if err != nil {
@ -2004,24 +2052,26 @@ func (h *SpaceHandler) CreateBudget(w http.ResponseWriter, r *http.Request) {
return
}
tagIDsStr := r.FormValue("tag_ids")
tagNames := r.Form["tags"]
amountStr := r.FormValue("amount")
periodStr := r.FormValue("period")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
var tagIDs []string
if tagIDsStr != "" {
for _, id := range strings.Split(tagIDsStr, ",") {
id = strings.TrimSpace(id)
if id != "" {
tagIDs = append(tagIDs, id)
}
}
if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
return
}
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
tagIDs, err := h.processTagNames(spaceID, tagNames)
if err != nil {
slog.Error("failed to process tag names", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(tagIDs) == 0 {
ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity)
return
}
@ -2082,24 +2132,26 @@ func (h *SpaceHandler) UpdateBudget(w http.ResponseWriter, r *http.Request) {
return
}
tagIDsStr := r.FormValue("tag_ids")
tagNames := r.Form["tags"]
amountStr := r.FormValue("amount")
periodStr := r.FormValue("period")
startDateStr := r.FormValue("start_date")
endDateStr := r.FormValue("end_date")
var tagIDs []string
if tagIDsStr != "" {
for _, id := range strings.Split(tagIDsStr, ",") {
id = strings.TrimSpace(id)
if id != "" {
tagIDs = append(tagIDs, id)
}
}
if len(tagNames) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
return
}
if len(tagIDs) == 0 || amountStr == "" || periodStr == "" || startDateStr == "" {
ui.RenderError(w, r, "All required fields must be provided.", http.StatusUnprocessableEntity)
tagIDs, err := h.processTagNames(spaceID, tagNames)
if err != nil {
slog.Error("failed to process tag names", "error", err, "space_id", spaceID)
http.Error(w, "Internal Server Error", http.StatusInternalServerError)
return
}
if len(tagIDs) == 0 {
ui.RenderError(w, r, "At least one valid tag is required.", http.StatusUnprocessableEntity)
return
}

View file

@ -13,7 +13,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
)
type AddExpenseFormProps struct {
@ -139,16 +139,11 @@ templ AddExpenseForm(props AddExpenseFormProps) {
@label.Label(label.Props{For: "new-expense-tags"}) {
Tags (Optional)
}
<datalist id="available-tags">
for _, tag := range props.Tags {
<option value={ tag.Name }></option>
}
</datalist>
@tagsinput.TagsInput(tagsinput.Props{
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "new-expense-tags",
Name: "tags",
Placeholder: "Add tags (press enter)",
Attributes: templ.Attributes{"list": "available-tags"},
Tags: props.Tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@ -163,7 +158,7 @@ templ AddExpenseForm(props AddExpenseFormProps) {
</form>
}
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-expense-" + exp.ID }}
{{ tagValues := make([]string, len(exp.Tags)) }}
for i, t := range exp.Tags {
@ -248,11 +243,12 @@ templ EditExpenseForm(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
@label.Label(label.Props{For: "edit-tags-" + exp.ID}) {
Tags (Optional)
}
@tagsinput.TagsInput(tagsinput.Props{
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-tags-" + exp.ID,
Name: "tags",
Value: tagValues,
Placeholder: "Add tags (press enter)",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method

View file

@ -14,7 +14,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/paymentmethod"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
)
func frequencyLabel(f model.Frequency) string {
@ -34,7 +34,7 @@ func frequencyLabel(f model.Frequency) string {
}
}
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ delDialogID := "del-recurring-" + re.ID }}
<div id={ "recurring-" + re.ID } class="p-4 flex justify-between items-start gap-2">
@ -113,7 +113,7 @@ templ RecurringItem(spaceID string, re *model.RecurringExpenseWithTagsAndMethod,
Update the details of this recurring transaction.
}
}
@EditRecurringForm(spaceID, re, methods)
@EditRecurringForm(spaceID, re, methods, tags)
}
}
// Delete button
@ -269,16 +269,11 @@ templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.Payme
@label.Label(label.Props{For: "recurring-tags"}) {
Tags
}
<datalist id="recurring-available-tags">
for _, t := range tags {
<option value={ t.Name }></option>
}
</datalist>
@tagsinput.TagsInput(tagsinput.Props{
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "recurring-tags",
Name: "tags",
Placeholder: "Add tags (press enter)",
Attributes: templ.Attributes{"list": "recurring-available-tags"},
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method
@ -291,7 +286,7 @@ templ AddRecurringForm(spaceID string, tags []*model.Tag, methods []*model.Payme
</form>
}
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
{{ editDialogID := "edit-recurring-" + re.ID }}
{{ tagValues := make([]string, len(re.Tags)) }}
for i, t := range re.Tags {
@ -422,11 +417,12 @@ templ EditRecurringForm(spaceID string, re *model.RecurringExpenseWithTagsAndMet
@label.Label(label.Props{For: "edit-recurring-tags-" + re.ID}) {
Tags
}
@tagsinput.TagsInput(tagsinput.Props{
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-recurring-tags-" + re.ID,
Name: "tags",
Value: tagValues,
Placeholder: "Add tags (press enter)",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Payment Method

View file

@ -0,0 +1,158 @@
package tagcombobox
import (
"strings"
"git.juancwu.dev/juancwu/budgit/internal/model"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgit/internal/utils"
)
type Props struct {
ID string
Name string // form field name (default "tags")
Value []string // pre-selected tag names
Tags []*model.Tag // all available tags in the space
Placeholder string
HasError bool
Disabled bool
Form string // associate with external form
}
func (p Props) fieldName() string {
if p.Name != "" {
return p.Name
}
return "tags"
}
func (p Props) isSelected(tagName string) bool {
lower := strings.ToLower(tagName)
for _, v := range p.Value {
if strings.ToLower(v) == lower {
return true
}
}
return false
}
templ TagCombobox(props Props) {
<div
id={ props.ID + "-container" }
class={
utils.TwMerge(
"relative w-full",
),
}
data-tagcombobox
data-tagcombobox-name={ props.fieldName() }
data-tagcombobox-form={ props.Form }
>
// Main input area styled like tagsinput
<div
class={
utils.TwMerge(
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none cursor-text",
"dark:bg-input/30",
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
utils.If(props.Disabled, "opacity-50 cursor-not-allowed"),
"w-full min-h-[38px]",
utils.If(props.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
),
}
data-tagcombobox-input-area
>
// Selected tag chips
<div class="flex items-center flex-wrap gap-2" data-tagcombobox-chips>
for _, val := range props.Value {
@badge.Badge(badge.Props{
Attributes: templ.Attributes{"data-tagcombobox-chip": val},
}) {
<span>{ val }</span>
<button
type="button"
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
disabled?={ props.Disabled }
data-tagcombobox-remove={ val }
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-3 w-3 pointer-events-none" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
}
}
</div>
// Text input for searching/typing
<input
type="text"
id={ props.ID }
class="border-0 shadow-none focus-visible:ring-0 focus-visible:outline-none h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent flex-1 min-w-[80px] text-sm"
placeholder={ props.Placeholder }
disabled?={ props.Disabled }
autocomplete="off"
data-tagcombobox-text-input
/>
</div>
// Dropdown
<div
class="absolute z-50 mt-1 w-full rounded-md border bg-popover text-popover-foreground shadow-md hidden max-h-60 overflow-y-auto"
data-tagcombobox-dropdown
>
for _, tag := range props.Tags {
<div
class={
"flex items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground",
templ.KV("font-medium", props.isSelected(tag.Name)),
}
data-tagcombobox-item={ tag.Name }
>
<svg
xmlns="http://www.w3.org/2000/svg"
class={
"h-4 w-4 shrink-0",
templ.KV("opacity-100", props.isSelected(tag.Name)),
templ.KV("opacity-0", !props.isSelected(tag.Name)),
}
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
stroke-width="2"
data-tagcombobox-check
>
<path stroke-linecap="round" stroke-linejoin="round" d="M5 13l4 4L19 7"></path>
</svg>
if tag.Color != nil {
<span class="inline-block w-3 h-3 rounded-full shrink-0" style={ "background-color: " + *tag.Color }></span>
}
<span data-tagcombobox-item-label>{ tag.Name }</span>
</div>
}
// "Create new" option (hidden by default, shown when typing something new)
<div
class="hidden items-center gap-2 px-3 py-2 text-sm cursor-pointer hover:bg-accent hover:text-accent-foreground text-muted-foreground"
data-tagcombobox-create
>
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"></path>
</svg>
<span>Create "<span data-tagcombobox-create-label></span>"</span>
</div>
</div>
// Hidden inputs for form submission
<div data-tagcombobox-hidden-inputs>
for _, val := range props.Value {
<input
type="hidden"
name={ props.fieldName() }
value={ val }
if props.Form != "" {
form={ props.Form }
}
/>
}
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ utils.ScriptURL("/assets/js/tagcombobox.js") }></script>
}

View file

@ -13,6 +13,7 @@ import "git.juancwu.dev/juancwu/budgit/internal/ui/components/datepicker"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/progress"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagsinput"
import "git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
import "fmt"
import "time"
@ -53,6 +54,7 @@ templ Base(props ...SEOProps) {
@progress.Script()
@tagsinput.Script()
@selectbox.Script()
@tagcombobox.Script()
// Site-wide enhancements
@themeScript()
// Must run before body to prevent flash

View file

@ -11,7 +11,7 @@ import (
"git.juancwu.dev/juancwu/budgit/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/radio"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/selectbox"
"git.juancwu.dev/juancwu/budgit/internal/ui/components/tagcombobox"
"git.juancwu.dev/juancwu/budgit/internal/ui/layouts"
)
@ -37,15 +37,6 @@ func progressBarColor(status model.BudgetStatus) string {
}
}
func budgetTagSelected(tags []*model.Tag, tagID string) bool {
for _, t := range tags {
if t.ID == tagID {
return true
}
}
return false
}
templ SpaceBudgetsPage(space *model.Space, budgets []*model.BudgetWithSpent, tags []*model.Tag) {
@layouts.Space("Budgets", space) {
<div class="space-y-4">
@ -190,23 +181,17 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
class="space-y-4"
>
@csrf.Token()
// Tag selector (multi-select)
// Tag selector
<div>
@label.Label(label.Props{}) {
@label.Label(label.Props{For: "budget-tags"}) {
Tags
}
@selectbox.SelectBox(selectbox.Props{ID: "budget-tags", Multiple: true}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
}
@selectbox.Content() {
for _, t := range tags {
@selectbox.Item(selectbox.ItemProps{Value: t.ID}) {
{ t.Name }
}
}
}
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "budget-tags",
Name: "tags",
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>
@ -290,6 +275,10 @@ templ AddBudgetForm(spaceID string, tags []*model.Tag) {
templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag) {
{{ editDialogID := "edit-budget-" + b.ID }}
{{ budgetTagNames := make([]string, len(b.Tags)) }}
for i, t := range b.Tags {
{{ budgetTagNames[i] = t.Name }}
}
<form
hx-patch={ fmt.Sprintf("/app/spaces/%s/budgets/%s", spaceID, b.ID) }
hx-target="#budgets-list-wrapper"
@ -298,23 +287,18 @@ templ EditBudgetForm(spaceID string, b *model.BudgetWithSpent, tags []*model.Tag
class="space-y-4"
>
@csrf.Token()
// Tag selector (multi-select with pre-selected tags)
// Tag selector
<div>
@label.Label(label.Props{}) {
@label.Label(label.Props{For: "edit-budget-tags-" + b.ID}) {
Tags
}
@selectbox.SelectBox(selectbox.Props{ID: "edit-budget-tags-" + b.ID, Multiple: true}) {
@selectbox.Trigger(selectbox.TriggerProps{Name: "tag_ids", Multiple: true, ShowPills: true, SelectedCountText: "{n} tags selected"}) {
@selectbox.Value(selectbox.ValueProps{Placeholder: "Select tags..."})
}
@selectbox.Content() {
for _, t := range tags {
@selectbox.Item(selectbox.ItemProps{Value: t.ID, Selected: budgetTagSelected(b.Tags, t.ID)}) {
{ t.Name }
}
}
}
}
@tagcombobox.TagCombobox(tagcombobox.Props{
ID: "edit-budget-tags-" + b.ID,
Name: "tags",
Value: budgetTagNames,
Tags: tags,
Placeholder: "Search or create tags...",
})
</div>
// Amount
<div>

View file

@ -48,21 +48,21 @@ templ SpaceExpensesPage(space *model.Space, expenses []*model.ExpenseWithTagsAnd
// List of expenses
<div class="border rounded-lg">
<div id="expenses-list-wrapper">
@ExpensesListContent(space.ID, expenses, methods, currentPage, totalPages)
@ExpensesListContent(space.ID, expenses, methods, tags, currentPage, totalPages)
</div>
</div>
</div>
}
}
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, currentPage, totalPages int) {
templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag, currentPage, totalPages int) {
<h2 class="text-lg font-semibold p-4">History</h2>
<div id="expenses-list" class="divide-y">
if len(expenses) == 0 {
<p class="p-4 text-sm text-muted-foreground">No expenses recorded yet.</p>
}
for _, exp := range expenses {
@ExpenseListItem(spaceID, exp, methods)
@ExpenseListItem(spaceID, exp, methods, tags)
}
</div>
if totalPages > 1 {
@ -109,7 +109,7 @@ templ ExpensesListContent(spaceID string, expenses []*model.ExpenseWithTagsAndMe
}
}
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod) {
templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, methods []*model.PaymentMethod, tags []*model.Tag) {
<div id={ "expense-" + exp.ID } class="p-4 flex justify-between items-start gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
@ -166,7 +166,7 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
Update the details of this transaction.
}
}
@expense.EditExpenseForm(spaceID, exp, methods)
@expense.EditExpenseForm(spaceID, exp, methods, tags)
}
}
// Delete button
@ -208,12 +208,12 @@ templ ExpenseListItem(spaceID string, exp *model.ExpenseWithTagsAndMethod, metho
</div>
}
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, nil, currentPage, totalPages)
templ ExpenseCreatedResponse(spaceID string, expenses []*model.ExpenseWithTagsAndMethod, balance int, allocated int, tags []*model.Tag, currentPage, totalPages int) {
@ExpensesListContent(spaceID, expenses, nil, tags, currentPage, totalPages)
@expense.BalanceCard(spaceID, balance, allocated, true)
}
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod) {
@ExpenseListItem(spaceID, exp, methods)
templ ExpenseUpdatedResponse(spaceID string, exp *model.ExpenseWithTagsAndMethod, balance int, allocated int, methods []*model.PaymentMethod, tags []*model.Tag) {
@ExpenseListItem(spaceID, exp, methods, tags)
@expense.BalanceCard(exp.SpaceID, balance, allocated, true)
}

View file

@ -38,7 +38,7 @@ templ SpaceRecurringPage(space *model.Space, recs []*model.RecurringExpenseWithT
<p class="p-4 text-sm text-muted-foreground">No recurring transactions set up yet.</p>
}
for _, re := range recs {
@recurring.RecurringItem(space.ID, re, methods)
@recurring.RecurringItem(space.ID, re, methods, tags)
}
</div>
</div>