feat: add tag combobox to make tagging expenses easier
This commit is contained in:
parent
0e5d196432
commit
ef37360da7
9 changed files with 671 additions and 106 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
158
internal/ui/components/tagcombobox/tagcombobox.templ
Normal file
158
internal/ui/components/tagcombobox/tagcombobox.templ
Normal 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>
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue