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

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