init go project

This commit is contained in:
juancwu 2025-10-10 08:14:33 -04:00
commit 5dde43e409
85 changed files with 16720 additions and 0 deletions

View file

@ -0,0 +1,126 @@
// templui component accordion - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/accordion
package accordion
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Accordion(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<details
if p.ID != "" {
id={ p.ID }
}
name="accordion"
class={
utils.TwMerge(
"group border-b last:border-b-0",
"[&[open]>summary>svg]:rotate-180",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</details>
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<summary
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex flex-1 items-start justify-between gap-4 py-4",
"text-left text-sm font-medium",
"transition-all hover:underline cursor-pointer",
"outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring rounded-md",
"disabled:pointer-events-none disabled:opacity-50",
"list-none [&::-webkit-details-marker]:hidden",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
@icon.ChevronDown(icon.Props{
Size: 16,
Class: "size-4 shrink-0 translate-y-0.5 transition-transform duration-200 text-muted-foreground pointer-events-none",
})
</summary>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"pt-0 pb-4 text-sm overflow-hidden",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}

View file

@ -0,0 +1,110 @@
// templui component alert - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/alert
package alert
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Variant string
const (
VariantDefault Variant = "default"
VariantDestructive Variant = "destructive"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Variant Variant
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Alert(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
data-slot="alert"
class={
utils.TwMerge(
"relative w-full rounded-lg border px-4 py-3 text-sm",
"grid has-[>svg]:grid-cols-[1rem_1fr] grid-cols-[0_1fr] has-[>svg]:gap-x-3 gap-y-0.5 items-start",
"[&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current",
variantClasses(p.Variant),
p.Class,
),
}
role="alert"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Title(props ...TitleProps) {
{{ var p TitleProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<h5
if p.ID != "" {
id={ p.ID }
}
data-slot="alert-title"
class={
utils.TwMerge(
"col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</h5>
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
data-slot="alert-description"
class={
utils.TwMerge(
"text-muted-foreground col-start-2 grid justify-items-start gap-1 text-sm [&_p]:leading-relaxed",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
func variantClasses(variant Variant) string {
switch variant {
case VariantDestructive:
return "text-destructive bg-card [&>svg]:text-current *:data-[slot=alert-description]:text-destructive/90"
default:
return "bg-card text-card-foreground"
}
}

View file

@ -0,0 +1,63 @@
// templui component aspectratio - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/aspect-ratio
package aspectratio
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Ratio string
const (
RatioAuto Ratio = "auto"
RatioSquare Ratio = "square"
RatioVideo Ratio = "video"
RatioPortrait Ratio = "portrait"
RatioWide Ratio = "wide"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Ratio Ratio
}
templ AspectRatio(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative w-full",
ratioClass(p.Ratio),
p.Class,
),
}
{ p.Attributes... }
>
<div class="absolute inset-0">
{ children... }
</div>
</div>
}
func ratioClass(ratio Ratio) string {
switch ratio {
case RatioSquare:
return "aspect-square"
case RatioVideo:
return "aspect-video"
case RatioPortrait:
return "aspect-[3/4]"
case RatioWide:
return "aspect-[2/1]"
case RatioAuto:
return "aspect-auto"
default:
return "aspect-auto"
}
}

View file

@ -0,0 +1,97 @@
// templui component avatar - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/avatar
package avatar
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type ImageProps struct {
ID string
Class string
Attributes templ.Attributes
Alt string
Src string
}
type FallbackProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Avatar(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
data-tui-avatar
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Image(props ...ImageProps) {
{{ var p ImageProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<img
data-tui-avatar-image
if p.ID != "" {
id={ p.ID }
}
if p.Src != "" {
src={ p.Src }
}
alt={ p.Alt }
class={
utils.TwMerge(
"aspect-square h-full w-full",
p.Class,
),
}
{ p.Attributes... }
/>
}
templ Fallback(props ...FallbackProps) {
{{ var p FallbackProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
data-tui-avatar-fallback
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex h-full w-full items-center justify-center rounded-full bg-muted",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</span>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/avatar.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,59 @@
// templui component badge - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/badge
package badge
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Variant string
const (
VariantDefault Variant = "default"
VariantSecondary Variant = "secondary"
VariantDestructive Variant = "destructive"
VariantOutline Variant = "outline"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Variant Variant
}
templ Badge(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"inline-flex items-center justify-center rounded-md border px-2 py-0.5 text-xs font-medium w-fit whitespace-nowrap shrink-0 [&>svg]:size-3 gap-1 [&>svg]:pointer-events-none",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"transition-[color,box-shadow] overflow-hidden",
p.variantClasses(),
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</span>
}
func (p Props) variantClasses() string {
switch p.Variant {
case VariantDestructive:
return "border-transparent bg-destructive text-white [a&]:hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
case VariantOutline:
return "text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground"
case VariantSecondary:
return "border-transparent bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90"
default:
return "border-transparent bg-primary text-primary-foreground [a&]:hover:bg-primary/90"
}
}

View file

@ -0,0 +1,176 @@
// templui component breadcrumb - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/breadcrumb
package breadcrumb
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type ListProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
Current bool
}
type LinkProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
UseCustom bool
}
templ Breadcrumb(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<nav
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex",
p.Class,
),
}
aria-label="Breadcrumb"
{ p.Attributes... }
>
{ children... }
</nav>
}
templ List(props ...ListProps) {
{{ var p ListProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<ol
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center flex-wrap gap-1 text-sm",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</ol>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<li
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</li>
}
templ Link(props ...LinkProps) {
{{ var p LinkProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<a
if p.ID != "" {
id={ p.ID }
}
if p.Href != "" {
href={ templ.SafeURL(p.Href) }
}
class={
utils.TwMerge(
"text-muted-foreground hover:text-foreground hover:underline flex items-center gap-1.5 transition-colors",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</a>
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"mx-2 text-muted-foreground",
p.Class,
),
}
{ p.Attributes... }
>
if p.UseCustom {
{ children... }
} else {
@icon.ChevronRight(icon.Props{Size: 14, Class: "text-muted-foreground"})
}
</span>
}
templ Page(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"font-medium text-foreground flex items-center gap-1.5",
p.Class,
),
}
aria-current="page"
{ p.Attributes... }
>
{ children... }
</span>
}

View file

@ -0,0 +1,152 @@
// templui component button - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/button
package button
import (
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strings"
)
type Variant string
type Size string
type Type string
const (
VariantDefault Variant = "default"
VariantDestructive Variant = "destructive"
VariantOutline Variant = "outline"
VariantSecondary Variant = "secondary"
VariantGhost Variant = "ghost"
VariantLink Variant = "link"
)
const (
TypeButton Type = "button"
TypeReset Type = "reset"
TypeSubmit Type = "submit"
)
const (
SizeDefault Size = "default"
SizeSm Size = "sm"
SizeLg Size = "lg"
SizeIcon Size = "icon"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Variant Variant
Size Size
FullWidth bool
Href string
Target string
Disabled bool
Type Type
Form string
}
templ Button(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = TypeButton }}
}
if p.Href != "" && !p.Disabled {
<a
if p.ID != "" {
id={ p.ID }
}
href={ templ.SafeURL(p.Href) }
if p.Target != "" {
target={ p.Target }
}
class={
utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all",
"disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0",
"outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"cursor-pointer",
p.variantClasses(),
p.sizeClasses(),
p.modifierClasses(),
p.Class,
),
}
if p.Type != "" {
type={ string(p.Type) }
}
if p.Form != "" {
form={ p.Form }
}
disabled?={ p.Disabled }
{ p.Attributes... }
>
{ children... }
</button>
}
}
func (b Props) variantClasses() string {
switch b.Variant {
case VariantDestructive:
return "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60"
case VariantOutline:
return "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50"
case VariantSecondary:
return "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80"
case VariantGhost:
return "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
case VariantLink:
return "text-primary underline-offset-4 hover:underline"
default:
return "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90"
}
}
func (b Props) sizeClasses() string {
switch b.Size {
case SizeSm:
return "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5"
case SizeLg:
return "h-10 rounded-md px-6 has-[>svg]:px-4"
case SizeIcon:
return "size-9"
default: // SizeDefault
return "h-9 px-4 py-2 has-[>svg]:px-3"
}
}
func (b Props) modifierClasses() string {
classes := []string{}
if b.FullWidth {
classes = append(classes, "w-full")
}
return strings.Join(classes, " ")
}

View file

@ -0,0 +1,195 @@
// templui component calendar - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/calendar
package calendar
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
"time"
)
type LocaleTag string
var (
LocaleDefaultTag = LocaleTag("en-US")
LocaleTagChinese = LocaleTag("zh-CN")
LocaleTagFrench = LocaleTag("fr-FR")
LocaleTagGerman = LocaleTag("de-DE")
LocaleTagItalian = LocaleTag("it-IT")
LocaleTagJapanese = LocaleTag("ja-JP")
LocaleTagPortuguese = LocaleTag("pt-PT")
LocaleTagSpanish = LocaleTag("es-ES")
)
type Day int
var (
Sunday = Day(0)
Monday = Day(1)
Tuesday = Day(2)
Wednesday = Day(3)
Thursday = Day(4)
Friday = Day(5)
Saturday = Day(6)
)
type Props struct {
ID string
Class string
LocaleTag LocaleTag
Value *time.Time
Name string
InitialMonth int // Optional: 0-11 (Default: current or from Value). Controls the initially displayed month view.
InitialYear int // Optional: (Default: current or from Value). Controls the initially displayed year view.
StartOfWeek *Day // Optional: 0-6 [Sun-Sat] (Default: 1).
RenderHiddenInput bool // Optional: Whether to render the hidden input (Default: true). Set to false when used inside DatePicker.
}
templ Calendar(props ...Props) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
if p.ID == "" {
p.ID = utils.RandomID() + "-calendar"
}
if p.Name == "" {
// Should be provided by parent (e.g., DatePicker or in standalone usage)
p.Name = p.ID + "-value" // Fallback name
}
if p.LocaleTag == "" {
p.LocaleTag = LocaleDefaultTag
}
// Default to rendering hidden input unless explicitly set to false
if p.RenderHiddenInput == false && len(props) > 0 {
// Only respect false if it was explicitly passed
p.RenderHiddenInput = props[0].RenderHiddenInput
} else {
p.RenderHiddenInput = true
}
initialStartOfWeek := Monday
if p.StartOfWeek != nil {
initialStartOfWeek = *p.StartOfWeek
}
initialView := time.Now()
if p.Value != nil {
initialView = *p.Value
}
initialMonth := p.InitialMonth
initialYear := p.InitialYear
// Use year from initialView if InitialYear prop is invalid/unset (<= 0)
if initialYear <= 0 {
initialYear = initialView.Year()
}
// Use month from initialView if InitialMonth prop is invalid OR
// if InitialMonth is default 0 AND InitialYear was also defaulted (meaning neither was likely set explicitly)
if (initialMonth < 0 || initialMonth > 11) || (initialMonth == 0 && p.InitialYear <= 0) {
initialMonth = int(initialView.Month()) - 1 // time.Month is 1-12
}
initialSelectedISO := ""
if p.Value != nil {
initialSelectedISO = p.Value.Format("2006-01-02")
}
// For SelectBox display
currentMonth := initialMonth
currentYear := initialYear
// Generate short month names (English only, JS will update with localized)
monthNames := []string{"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"}
}}
<div class={ p.Class } id={ p.ID + "-wrapper" } data-tui-calendar-wrapper="true">
if p.RenderHiddenInput {
<input
type="hidden"
name={ p.Name }
value={ initialSelectedISO }
id={ p.ID + "-hidden" }
data-tui-calendar-hidden-input
/>
}
<div
id={ p.ID }
data-tui-calendar-container="true"
data-tui-calendar-locale-tag={ string(p.LocaleTag) }
data-tui-calendar-initial-month={ strconv.Itoa(initialMonth) }
data-tui-calendar-initial-year={ strconv.Itoa(initialYear) }
data-tui-calendar-selected-date={ initialSelectedISO }
data-tui-calendar-start-of-week={ int(initialStartOfWeek) }
>
<!-- Calendar Header -->
<div class="flex items-center gap-2 mb-4">
<button
type="button"
data-tui-calendar-prev
class="inline-flex items-center justify-center rounded-md text-sm font-medium h-7 w-7 hover:bg-accent hover:text-accent-foreground focus:outline-none disabled:opacity-50 shrink-0"
>
@icon.ChevronLeft()
</button>
<div class="flex gap-2 flex-1 min-w-0">
<!-- Month Select -->
<div class="relative flex-1 has-[:focus]:border-ring border border-input shadow-xs has-[:focus]:ring-ring/50 has-[:focus]:ring-[3px] rounded-md">
<select
id={ p.ID + "-month-select" }
data-tui-calendar-month-select
class="absolute inset-0 opacity-0 cursor-pointer w-full"
aria-label="Choose the Month"
>
for i := 0; i < 12; i++ {
<option value={ strconv.Itoa(i) } selected?={ i == currentMonth } data-tui-calendar-month-index={ strconv.Itoa(i) }>
{ monthNames[i] }
</option>
}
</select>
<span class="select-none font-medium rounded-md px-2 flex items-center justify-center gap-1 text-sm h-7 pointer-events-none" aria-hidden="true">
<span id={ p.ID + "-month-value" }>{ monthNames[currentMonth] }</span>
@icon.ChevronDown(icon.Props{Size: 14, Class: "text-muted-foreground"})
</span>
</div>
<!-- Year Select -->
<div class="relative flex-1 has-[:focus]:border-ring border border-input shadow-xs has-[:focus]:ring-ring/50 has-[:focus]:ring-[3px] rounded-md">
<select
id={ p.ID + "-year-select" }
data-tui-calendar-year-select
class="absolute inset-0 opacity-0 cursor-pointer w-full"
aria-label="Choose the Year"
>
for year := 2100; year >= 1900; year-- {
<option value={ strconv.Itoa(year) } selected?={ year == currentYear }>
{ strconv.Itoa(year) }
</option>
}
</select>
<span class="select-none font-medium rounded-md px-2 flex items-center justify-center gap-1 text-sm h-7 pointer-events-none" aria-hidden="true">
<span id={ p.ID + "-year-value" }>{ strconv.Itoa(currentYear) }</span>
@icon.ChevronDown(icon.Props{Size: 14, Class: "text-muted-foreground"})
</span>
</div>
</div>
<button
type="button"
data-tui-calendar-next
class="inline-flex items-center justify-center rounded-md text-sm font-medium h-7 w-7 hover:bg-accent hover:text-accent-foreground focus:outline-none disabled:opacity-50 shrink-0"
>
@icon.ChevronRight()
</button>
</div>
<!-- Weekday Headers -->
<div data-tui-calendar-weekdays class="grid grid-cols-7 gap-1 mb-1 place-items-center"></div>
<!-- Calendar Day Grid -->
<div data-tui-calendar-days class="grid grid-cols-7 gap-1 place-items-center"></div>
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/calendar.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,167 @@
// templui component card - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/card
package card
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Card(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"w-full rounded-lg border bg-card text-card-foreground shadow-xs",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex flex-col space-y-1.5 p-6 pb-0",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Title(props ...TitleProps) {
{{ var p TitleProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<h3
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"text-lg font-semibold leading-none tracking-tight",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</h3>
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<p
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"text-sm text-muted-foreground",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</p>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-6",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center p-6 pt-0",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}

View file

@ -0,0 +1,211 @@
// templui component carousel - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/carousel
package carousel
import (
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Autoplay bool
Interval int
Loop bool
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type PreviousProps struct {
ID string
Class string
Attributes templ.Attributes
}
type NextProps struct {
ID string
Class string
Attributes templ.Attributes
}
type IndicatorsProps struct {
ID string
Class string
Attributes templ.Attributes
Count int
}
templ Carousel(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"relative overflow-hidden w-full",
p.Class,
),
}
data-tui-carousel
data-tui-carousel-current="0"
data-tui-carousel-autoplay={ strconv.FormatBool(p.Autoplay) }
data-tui-carousel-interval={ fmt.Sprintf("%d", func() int {
if p.Interval == 0 {
return 5000
}
return p.Interval
}()) }
data-tui-carousel-loop={ strconv.FormatBool(p.Loop) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex h-full w-full transition-transform duration-500 ease-in-out cursor-grab",
p.Class,
),
}
data-tui-carousel-track
{ p.Attributes... }
>
{ children... }
</div>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex-shrink-0 w-full h-full relative",
p.Class,
),
}
data-tui-carousel-item
{ p.Attributes... }
>
{ children... }
</div>
}
templ Previous(props ...PreviousProps) {
{{ var p PreviousProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<button
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"absolute left-2 top-1/2 transform -translate-y-1/2 p-2 rounded-full bg-black/20 text-white hover:bg-black/40 focus:outline-none",
p.Class,
),
}
data-tui-carousel-prev
aria-label="Previous slide"
type="button"
{ p.Attributes... }
>
@icon.ChevronLeft()
</button>
}
templ Next(props ...NextProps) {
{{ var p NextProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<button
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"absolute right-2 top-1/2 transform -translate-y-1/2 p-2 rounded-full bg-black/20 text-white hover:bg-black/40 focus:outline-none",
p.Class,
),
}
data-tui-carousel-next
aria-label="Next slide"
type="button"
{ p.Attributes... }
>
@icon.ChevronRight()
</button>
}
templ Indicators(props ...IndicatorsProps) {
{{ var p IndicatorsProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"absolute bottom-4 left-1/2 transform -translate-x-1/2 flex gap-2",
p.Class,
),
}
{ p.Attributes... }
>
for i := 0; i < p.Count; i++ {
<button
class={
utils.TwMerge(
"w-3 h-3 rounded-full bg-foreground/30 hover:bg-foreground/50 focus:outline-none transition-colors",
utils.If(i == 0, "bg-primary"),
),
}
data-tui-carousel-indicator={ strconv.Itoa(i) }
data-tui-carousel-active={ utils.IfElse(i == 0, "true", "false") }
aria-label={ fmt.Sprintf("Go to slide %d", i+1) }
type="button"
></button>
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/carousel.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,113 @@
// templui component chart - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/charts
package chart
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Variant string
const (
VariantBar Variant = "bar"
VariantLine Variant = "line"
VariantPie Variant = "pie"
VariantDoughnut Variant = "doughnut"
VariantRadar Variant = "radar"
)
type Dataset struct {
Label string `json:"label"`
Data []float64 `json:"data"`
BorderWidth int `json:"borderWidth,omitempty"`
BorderColor interface{} `json:"borderColor,omitempty"`
BackgroundColor interface{} `json:"backgroundColor,omitempty"`
Tension float64 `json:"tension,omitempty"`
Fill bool `json:"fill,omitempty"`
Stepped bool `json:"stepped,omitempty"`
}
type Options struct {
Responsive bool `json:"responsive,omitempty"`
Legend bool `json:"legend,omitempty"`
}
type Data struct {
Labels []string `json:"labels"`
Datasets []Dataset `json:"datasets"`
}
type Config struct {
Type Variant `json:"type"`
Data Data `json:"data"`
Options Options `json:"options,omitempty"`
ShowLegend bool `json:"showLegend,omitempty"`
ShowXAxis bool `json:"showXAxis"`
ShowYAxis bool `json:"showYAxis"`
ShowXLabels bool `json:"showXLabels"`
ShowYLabels bool `json:"showYLabels"`
ShowXGrid bool `json:"showXGrid"`
ShowYGrid bool `json:"showYGrid"`
Horizontal bool `json:"horizontal"`
Stacked bool `json:"stacked"`
}
type Props struct {
ID string
Variant Variant
Data Data
Options Options
ShowLegend bool
ShowXAxis bool
ShowYAxis bool
ShowXLabels bool
ShowYLabels bool
ShowXGrid bool
ShowYGrid bool
Horizontal bool
Stacked bool
Class string
Attributes templ.Attributes
}
templ Chart(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = "chart-" + utils.RandomID() }}
}
{{ canvasId := p.ID + "-canvas" }}
{{ dataId := p.ID + "-data" }}
<div
id={ p.ID }
class={
utils.TwMerge(
"chart-container relative",
p.Class),
}
{ p.Attributes... }
>
<canvas id={ canvasId } data-tui-chart-id={ dataId }></canvas>
</div>
{{
chartConfig := Config{
Type: p.Variant,
Data: p.Data,
Options: p.Options,
ShowLegend: p.ShowLegend,
ShowXAxis: p.ShowXAxis,
ShowYAxis: p.ShowYAxis,
ShowXLabels: p.ShowXLabels,
ShowYLabels: p.ShowYLabels,
ShowXGrid: p.ShowXGrid,
ShowYGrid: p.ShowYGrid,
Horizontal: p.Horizontal,
Stacked: p.Stacked,
}
}}
@templ.JSONScript(dataId, chartConfig)
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/chart.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,69 @@
// templui component checkbox - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/checkbox
package checkbox
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Value string
Disabled bool
Checked bool
Form string
Icon templ.Component
}
templ Checkbox(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div class="relative inline-flex items-center">
<input
checked?={ p.Checked }
disabled?={ p.Disabled }
if p.ID != "" {
id={ p.ID }
}
if p.Name != "" {
name={ p.Name }
}
if p.Value != "" {
value={ p.Value }
} else {
value="on"
}
if p.Form != "" {
form={ p.Form }
}
type="checkbox"
class={
utils.TwMerge(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs",
"focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-50",
"checked:bg-primary checked:text-primary-foreground checked:border-primary",
"appearance-none cursor-pointer transition-shadow",
"relative",
p.Class,
),
}
{ p.Attributes... }
/>
<div
class="absolute left-0 top-0 h-4 w-4 pointer-events-none flex items-center justify-center text-primary-foreground opacity-0 peer-checked:opacity-100"
>
if p.Icon != nil {
@p.Icon
} else {
@icon.Check(icon.Props{Size: 14})
}
</div>
</div>
}

View file

@ -0,0 +1,56 @@
// templui component code - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/code
package code
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attrs templ.Attributes
Language string
CodeClass string
}
templ Code(props ...Props) {
// Highlight.js with theme switching
<link
id="highlight-theme"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/atom-one-light.min.css"
rel="stylesheet"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = "code-" + utils.RandomID() }}
}
<div
id={ p.ID }
class={ utils.TwMerge("relative code-component", p.Class) }
data-tui-code-component
{ p.Attrs... }
>
<pre class="overflow-hidden!">
<code
data-tui-code-block
class={
utils.TwMerge(
"language-"+p.Language,
"overflow-y-auto! rounded-md block text-sm max-h-[500px]",
"hljs-target",
p.CodeClass,
),
}
>
{ children... }
</code>
</pre>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/code.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,86 @@
// templui component collapsible - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/collapsible
package collapsible
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
Open bool
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Collapsible(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<div
id={ p.ID }
class={ utils.TwMerge("", p.Class) }
data-tui-collapsible="root"
data-tui-collapsible-state={ utils.IfElse(p.Open, "open", "closed") }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("", p.Class) }
data-tui-collapsible="trigger"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"grid grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-out [[data-tui-collapsible-state=open]_&]:grid-rows-[1fr]",
p.Class,
) }
data-tui-collapsible="content"
{ p.Attributes... }
>
<div class="overflow-hidden">
{ children... }
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/collapsible.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,48 @@
// templui component copybutton - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/copy-button
package copybutton
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string // Optional button ID
Class string // Custom CSS classes
Attrs templ.Attributes // Additional HTML attributes
TargetID string // Required - ID of element to copy from
}
templ CopyButton(props Props) {
{{ var p = props }}
if p.ID == "" {
{{ p.ID = "copybutton-" + utils.RandomID() }}
}
<div
data-copy-button
data-target-id={ p.TargetID }
class="inline-block"
>
@button.Button(button.Props{
ID: p.ID,
Class: utils.TwMerge("h-7 w-7 text-muted-foreground hover:text-accent-foreground", p.Class),
Attributes: p.Attrs,
Size: button.SizeIcon,
Variant: button.VariantGhost,
Type: button.TypeButton,
}) {
<span data-copy-icon-clipboard>
@icon.Clipboard(icon.Props{Size: 16})
</span>
<span data-copy-icon-check class="hidden">
@icon.Check(icon.Props{Size: 16})
</span>
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/copybutton.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,158 @@
// templui component datepicker - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/date-picker
package datepicker
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/calendar"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"time"
)
type Format string
type LocaleTag string
const (
FormatLOCALE_SHORT Format = "locale-short" // Locale-specific short format (e.g., MM/DD/YY or DD.MM.YY)
FormatLOCALE_MEDIUM Format = "locale-medium" // Locale-specific medium format (e.g., Jan 5, 2024 or 5. Jan. 2024)
FormatLOCALE_LONG Format = "locale-long" // Locale-specific long format (e.g., January 5, 2024 or 5. Januar 2024)
FormatLOCALE_FULL Format = "locale-full" // Locale-specific full format (e.g., Monday, January 5, 2024 or Montag, 5. Januar 2024)
)
// Common Locale (BCP 47)
var (
LocaleDefaultTag = LocaleTag("en-US")
LocaleTagChinese = LocaleTag("zh-CN")
LocaleTagFrench = LocaleTag("fr-FR")
LocaleTagGerman = LocaleTag("de-DE")
LocaleTagItalian = LocaleTag("it-IT")
LocaleTagJapanese = LocaleTag("ja-JP")
LocaleTagPortuguese = LocaleTag("pt-PT")
LocaleTagSpanish = LocaleTag("es-ES")
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Value time.Time
Form string
Format Format // Controls the display format using Intl dateStyle options.
LocaleTag LocaleTag // BCP 47 Locale Tag (e.g., "en-US", "es-ES"). Determines language and regional format defaults.
StartOfWeek *calendar.Day // Optional: 0-6 [Sun-Sat] (Default: 1).
Placeholder string
Disabled bool
HasError bool
}
templ DatePicker(props ...Props) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
if p.ID == "" {
p.ID = utils.RandomID()
}
if p.Name == "" {
p.Name = p.ID
}
if p.Placeholder == "" {
p.Placeholder = "Select a date"
}
if p.LocaleTag == "" {
p.LocaleTag = LocaleDefaultTag
}
if p.Format == "" {
p.Format = FormatLOCALE_MEDIUM
}
var contentID = p.ID + "-content"
var valuePtr *time.Time
var initialSelectedISO string
if !p.Value.IsZero() {
valuePtr = &p.Value
initialSelectedISO = p.Value.Format("2006-01-02")
}
}}
<div class="relative inline-block w-full">
<input
type="hidden"
name={ p.Name }
value={ initialSelectedISO }
if p.Form != "" {
form={ p.Form }
}
id={ p.ID + "-hidden" }
data-tui-datepicker-hidden-input
/>
@popover.Trigger(popover.TriggerProps{For: contentID}) {
@button.Button(button.Props{
ID: p.ID,
Variant: button.VariantOutline,
Class: utils.TwMerge(
// Base styles matching input
"w-full h-9 px-3 py-1 text-base md:text-sm",
"flex items-center justify-between",
"rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
p.Class,
),
Disabled: p.Disabled,
Attributes: utils.MergeAttributes(p.Attributes, templ.Attributes{
"data-tui-datepicker": "true",
"data-tui-datepicker-display-format": string(p.Format),
"data-tui-datepicker-locale-tag": string(p.LocaleTag),
"data-tui-datepicker-placeholder": p.Placeholder,
"aria-invalid": utils.If(p.HasError, "true"),
}),
}) {
if p.Placeholder != "" {
<span data-tui-datepicker-display class={ "text-left grow text-muted-foreground" }>
{ p.Placeholder }
</span>
}
<span class="text-muted-foreground flex items-center ml-2">
@icon.Calendar(icon.Props{Size: 16})
</span>
}
}
@popover.Content(popover.ContentProps{
ID: contentID,
Placement: popover.PlacementBottomStart,
Class: "p-0",
}) {
@card.Card(card.Props{
Class: "border-0 shadow-none",
}) {
@card.Content(card.ContentProps{
Class: "p-3",
}) {
@calendar.Calendar(calendar.Props{
ID: p.ID + "-calendar-instance", // Pass ID for calendar instance
LocaleTag: calendar.LocaleTag(p.LocaleTag), // Pass locale tag to calendar
StartOfWeek: p.StartOfWeek, // Pass start of week to calendar
Value: valuePtr, // Pass pointer to value
RenderHiddenInput: false, // Don't render hidden input inside popover
})
}
}
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/datepicker.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,332 @@
// templui component dialog - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/dialog
package dialog
import (
"context"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type contextKey string
const (
instanceKey contextKey = "dialogInstance"
openKey contextKey = "dialogOpen"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
DisableClickAway bool
DisableESC bool
Open bool
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
For string // Reference to a specific dialog ID (for external triggers)
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
HideCloseButton bool
Open bool // Initial open state for standalone usage (when no context)
DisableAutoFocus bool
}
type CloseProps struct {
ID string
Class string
Attributes templ.Attributes
For string // ID of the dialog to close (optional, defaults to closest dialog)
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Dialog(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ instanceID := p.ID }}
if instanceID == "" {
{{ instanceID = utils.RandomID() }}
}
{{ ctx = context.WithValue(ctx, instanceKey, instanceID) }}
{{ ctx = context.WithValue(ctx, openKey, p.Open) }}
<div
if p.ID != "" {
id={ p.ID }
}
data-tui-dialog
data-dialog-instance={ instanceID }
if p.DisableClickAway {
data-tui-dialog-disable-click-away="true"
}
if p.DisableESC {
data-tui-dialog-disable-esc="true"
}
class={ utils.TwMerge("", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ instanceID := "" }}
// Explicit For prop takes priority over inherited context
if p.For != "" {
{{ instanceID = p.For }}
} else if val := ctx.Value(instanceKey); val != nil {
{{ instanceID = val.(string) }}
}
<span
if p.ID != "" {
id={ p.ID }
}
data-tui-dialog-trigger={ instanceID }
data-dialog-instance={ instanceID }
data-tui-dialog-trigger-open="false"
class={ utils.TwMerge("contents", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Start with prop values as defaults
{{ instanceID := p.ID }}
{{ open := p.Open }}
// Override with context values if available
if val := ctx.Value(instanceKey); val != nil {
{{ instanceID = val.(string) }}
}
if val := ctx.Value(openKey); val != nil {
{{ open = val.(bool) }}
}
// Apply defaults if still empty
if instanceID == "" {
{{ instanceID = utils.RandomID() }}
}
<!-- Overlay -->
<div
class={ utils.TwMerge(
"fixed inset-0 z-50 bg-black/50",
"transition-opacity duration-300",
"data-[tui-dialog-open=false]:opacity-0",
"data-[tui-dialog-open=true]:opacity-100",
"data-[tui-dialog-open=false]:pointer-events-none",
"data-[tui-dialog-open=true]:pointer-events-auto",
"data-[tui-dialog-hidden=true]:!hidden",
) }
data-tui-dialog-backdrop
data-dialog-instance={ instanceID }
if open {
data-tui-dialog-open="true"
} else {
data-tui-dialog-open="false"
data-tui-dialog-hidden="true"
}
></div>
<!-- Content -->
<div
class={
utils.TwMerge(
// Base positioning
"fixed z-50 left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%]",
// Style
"bg-background rounded-lg border shadow-lg",
// Layout
"grid gap-4 p-6",
// Size
"w-full max-w-[calc(100%-2rem)] sm:max-w-lg",
// Transitions
"transition-all duration-200",
// Scale animation
"data-[tui-dialog-open=false]:scale-95",
"data-[tui-dialog-open=true]:scale-100",
// Opacity
"data-[tui-dialog-open=false]:opacity-0",
"data-[tui-dialog-open=true]:opacity-100",
// Pointer events
"data-[tui-dialog-open=false]:pointer-events-none",
"data-[tui-dialog-open=true]:pointer-events-auto",
// Hidden state
"data-[tui-dialog-hidden=true]:!hidden",
p.Class,
),
}
data-tui-dialog-content
data-dialog-instance={ instanceID }
if p.DisableAutoFocus {
data-tui-dialog-disable-autofocus="true"
}
if open {
data-tui-dialog-open="true"
} else {
data-tui-dialog-open="false"
data-tui-dialog-hidden="true"
}
{ p.Attributes... }
>
{ children... }
if !p.HideCloseButton {
<button
class={ utils.TwMerge(
// Positioning
"absolute top-4 right-4",
// Style
"rounded-xs opacity-70",
// Interactions
"transition-opacity hover:opacity-100",
// Focus states
"focus:outline-none focus:ring-2",
"focus:ring-ring focus:ring-offset-2",
"ring-offset-background",
// Hover/Data states
"data-[tui-dialog-open=true]:bg-accent",
"data-[tui-dialog-open=true]:text-muted-foreground",
// Disabled state
"disabled:pointer-events-none",
// Icon styles
"[&_svg]:pointer-events-none",
"[&_svg]:shrink-0",
"[&_svg:not([class*='size-'])]:size-4",
) }
data-tui-dialog-close={ instanceID }
aria-label="Close"
type="button"
>
@icon.X()
<span class="sr-only">Close</span>
</button>
}
</div>
}
templ Close(props ...CloseProps) {
{{ var p CloseProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
if p.For != "" {
data-tui-dialog-close={ p.For }
} else {
data-tui-dialog-close
}
class={ utils.TwMerge("contents cursor-pointer", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex flex-col gap-2 text-center sm:text-left", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex flex-col-reverse gap-2 sm:flex-row sm:justify-end", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Title(props ...TitleProps) {
{{ var p TitleProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<h2
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("text-lg leading-none font-semibold", p.Class) }
{ p.Attributes... }
>
{ children... }
</h2>
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<p
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("text-muted-foreground text-sm", p.Class) }
{ p.Attributes... }
>
{ children... }
</p>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/dialog.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,391 @@
// templui component dropdown - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/dropdown
package dropdown
import (
"context"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Placement = popover.Placement
const (
PlacementTop = popover.PlacementTop
PlacementTopStart = popover.PlacementTopStart
PlacementTopEnd = popover.PlacementTopEnd
PlacementRight = popover.PlacementRight
PlacementRightStart = popover.PlacementRightStart
PlacementRightEnd = popover.PlacementRightEnd
PlacementBottom = popover.PlacementBottom
PlacementBottomStart = popover.PlacementBottomStart
PlacementBottomEnd = popover.PlacementBottomEnd
PlacementLeft = popover.PlacementLeft
PlacementLeftStart = popover.PlacementLeftStart
PlacementLeftEnd = popover.PlacementLeftEnd
)
type contextKey string
var (
contentIDKey contextKey = "contentID"
subContentIDKey contextKey = "subContentID"
)
type Props struct {
ID string
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
Placement Placement
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type LabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
Disabled bool
Href string
Target string
PreventClose bool
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ShortcutProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubTriggerProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SubContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type PortalProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Dropdown(props ...Props) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
contentID := p.ID
if contentID == "" {
contentID = utils.RandomID()
}
ctx = context.WithValue(ctx, contentIDKey, contentID)
}}
{ children... }
}
templ Trigger(props ...TriggerProps) {
{{
var p TriggerProps
if len(props) > 0 {
p = props[0]
}
contentID, ok := ctx.Value(contentIDKey).(string)
if !ok {
contentID = "fallback-content-id"
}
}}
@popover.Trigger(popover.TriggerProps{
ID: p.ID,
Class: p.Class,
Attributes: p.Attributes,
For: contentID,
TriggerType: popover.TriggerTypeClick,
}) {
{ children... }
}
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ contentID, ok := ctx.Value(contentIDKey).(string) }}
if !ok {
{{ contentID = "fallback-content-id" }} // Must match fallback in Trigger
}
{{
placement := p.Placement
if placement == "" {
placement = PlacementBottomStart
}
}}
@popover.Content(popover.ContentProps{
ID: contentID,
Placement: placement,
Class: utils.TwMerge(
"z-50 rounded-md bg-popover p-1 shadow-md focus:outline-none overflow-auto",
"border border-border",
"min-w-[8rem] max-h-[300px]",
p.Class,
),
Attributes: p.Attributes,
Exclusive: true,
}) {
{ children... }
}
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("py-1", p.Class) }
role="group"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Label(props ...LabelProps) {
{{ var p LabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("px-2 py-1.5 text-sm font-semibold", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
if p.Href != "" {
<a
id={ p.ID }
if p.Href != "" {
href={ templ.SafeURL(p.Href) }
}
if p.Target != "" {
target={ p.Target }
}
class={
utils.TwMerge(
"flex text-left items-center justify-between px-2 py-1.5 text-sm rounded-sm",
utils.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
utils.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-tui-dropdown-item
if p.PreventClose {
data-tui-dropdown-prevent-close="true"
}
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
id={ p.ID }
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
utils.If(!p.Disabled, "focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default"),
utils.If(p.Disabled, "opacity-50 pointer-events-none"),
p.Class,
),
}
role="menuitem"
data-tui-dropdown-item
disabled?={ p.Disabled }
if p.PreventClose {
data-tui-dropdown-prevent-close="true"
}
{ p.Attributes... }
>
{ children... }
</button>
}
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("h-px my-1 -mx-1 bg-muted", p.Class) }
role="separator"
{ p.Attributes... }
></div>
}
templ Shortcut(props ...ShortcutProps) {
{{ var p ShortcutProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("ml-auto text-xs tracking-widest opacity-60", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Sub(props ...SubProps) {
{{
var p SubProps
if len(props) > 0 {
p = props[0]
}
subContentID := p.ID
if subContentID == "" {
subContentID = utils.RandomID()
}
ctx = context.WithValue(ctx, subContentIDKey, subContentID)
}}
<div
if p.ID != "" {
id={ p.ID }
}
data-tui-dropdown-submenu
class={ utils.TwMerge("relative", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ SubTrigger(props ...SubTriggerProps) {
{{
var p SubTriggerProps
if len(props) > 0 {
p = props[0]
}
subContentID, ok := ctx.Value(subContentIDKey).(string)
if !ok {
subContentID = "fallback-subcontent-id"
}
}}
@popover.Trigger(popover.TriggerProps{
ID: p.ID,
For: subContentID,
TriggerType: popover.TriggerTypeHover,
}) {
<button
type="button"
data-tui-dropdown-submenu-trigger
class={
utils.TwMerge(
"w-full text-left flex items-center justify-between px-2 py-1.5 text-sm rounded-sm",
"focus:bg-accent focus:text-accent-foreground hover:bg-accent hover:text-accent-foreground cursor-default",
p.Class,
),
}
{ p.Attributes... }
>
<span>
{ children... }
</span>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" class="h-4 w-4 ml-auto">
<path d="M6.5 3L11.5 8L6.5 13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
</svg>
</button>
}
}
templ SubContent(props ...SubContentProps) {
{{
var p SubContentProps
if len(props) > 0 {
p = props[0]
}
subContentID, ok := ctx.Value(subContentIDKey).(string)
if !ok {
subContentID = "fallback-subcontent-id"
}
}}
@popover.Content(popover.ContentProps{
ID: subContentID,
Placement: popover.PlacementRightStart,
Offset: -4, // Adjust as needed
HoverDelay: 100, // ms
HoverOutDelay: 200, // ms
Class: utils.TwMerge(
"z-[9999] min-w-[8rem] rounded-md border bg-popover p-1 shadow-lg",
p.Class,
),
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/dropdown.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,138 @@
// templui component form - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/form
package form
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/label"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type MessageVariant string
const (
MessageVariantError MessageVariant = "error"
MessageVariantInfo MessageVariant = "info"
)
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type LabelProps struct {
ID string
Class string
Attributes templ.Attributes
For string
DisabledClass string
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MessageProps struct {
ID string
Class string
Attributes templ.Attributes
Variant MessageVariant
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("space-y-2", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ ItemFlex(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("items-center flex space-x-2", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Label(props ...LabelProps) {
{{ var p LabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@label.Label(label.Props{
ID: p.ID,
Class: p.Class,
Attributes: p.Attributes,
For: p.For,
}) {
{ children... }
}
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<p
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("text-sm text-muted-foreground", p.Class) }
{ p.Attributes... }
>
{ children... }
</p>
}
templ Message(props ...MessageProps) {
{{ var p MessageProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<p
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"text-[0.8rem] font-medium",
messageVariantClass(p.Variant),
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</p>
}
func messageVariantClass(variant MessageVariant) string {
switch variant {
case MessageVariantError:
return "text-red-500"
case MessageVariantInfo:
return "text-blue-500"
default:
return ""
}
}

View file

@ -0,0 +1,118 @@
// templui component icon - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/icon
package icon
import (
"context"
"fmt"
"io"
"sync"
"github.com/a-h/templ"
)
// iconContents caches the fully generated SVG strings for icons that have been used,
// keyed by a composite key of name and props to handle different stylings.
var (
iconContents = make(map[string]string)
iconMutex sync.RWMutex
)
// Props defines the properties that can be set for an icon.
type Props struct {
Size int
Color string
Fill string
Stroke string
StrokeWidth string // Stroke Width of Icon, Usage: "2.5"
Class string
}
// Icon returns a function that generates a templ.Component for the specified icon name.
func Icon(name string) func(...Props) templ.Component {
return func(props ...Props) templ.Component {
var p Props
if len(props) > 0 {
p = props[0]
}
// Create a unique key for the cache based on icon name and all relevant props.
// This ensures different stylings of the same icon are cached separately.
cacheKey := fmt.Sprintf("%s|s:%d|c:%s|f:%s|sk:%s|sw:%s|cl:%s",
name, p.Size, p.Color, p.Fill, p.Stroke, p.StrokeWidth, p.Class)
return templ.ComponentFunc(func(ctx context.Context, w io.Writer) (err error) {
iconMutex.RLock()
svg, cached := iconContents[cacheKey]
iconMutex.RUnlock()
if cached {
_, err = w.Write([]byte(svg))
return err
}
// Not cached, generate it
// The actual generation now happens once and is cached.
generatedSvg, err := generateSVG(name, p) // p (Props) is passed to generateSVG
if err != nil {
// Provide more context in the error message
return fmt.Errorf("failed to generate svg for icon '%s' with props %+v: %w", name, p, err)
}
iconMutex.Lock()
iconContents[cacheKey] = generatedSvg
iconMutex.Unlock()
_, err = w.Write([]byte(generatedSvg))
return err
})
}
}
// generateSVG creates an SVG string for the specified icon with the given properties.
// This function is called when an icon-prop combination is not yet in the cache.
func generateSVG(name string, props Props) (string, error) {
// Get the raw, inner SVG content for the icon name from our internal data map.
content, err := getIconContent(name) // This now reads from internalSvgData
if err != nil {
return "", err // Error from getIconContent already includes icon name
}
size := props.Size
if size <= 0 {
size = 24 // Default size
}
fill := props.Fill
if fill == "" {
fill = "none" // Default fill
}
stroke := props.Stroke
if stroke == "" {
stroke = props.Color // Fallback to Color if Stroke is not set
}
if stroke == "" {
stroke = "currentColor" // Default stroke color
}
strokeWidth := props.StrokeWidth
if strokeWidth == "" {
strokeWidth = "2" // Default stroke width
}
// Construct the final SVG string.
// The data-lucide attribute helps identify these as Lucide icons if needed.
return fmt.Sprintf("<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%d\" height=\"%d\" viewBox=\"0 0 24 24\" fill=\"%s\" stroke=\"%s\" stroke-width=\"%s\" stroke-linecap=\"round\" stroke-linejoin=\"round\" class=\"%s\" data-lucide=\"icon\">%s</svg>",
size, size, fill, stroke, strokeWidth, props.Class, content), nil
}
// getIconContent retrieves the raw inner SVG content for a given icon name.
// It reads from the pre-generated internalSvgData map from icon_data.go.
func getIconContent(name string) (string, error) {
content, exists := internalSvgData[name]
if !exists {
return "", fmt.Errorf("icon '%s' not found in internalSvgData map", name)
}
return content, nil
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,130 @@
// templui component input - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/input
package input
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Type string
const (
TypeText Type = "text"
TypePassword Type = "password"
TypeEmail Type = "email"
TypeNumber Type = "number"
TypeTel Type = "tel"
TypeURL Type = "url"
TypeSearch Type = "search"
TypeDate Type = "date"
TypeDateTime Type = "datetime-local"
TypeTime Type = "time"
TypeFile Type = "file"
TypeColor Type = "color"
TypeWeek Type = "week"
TypeMonth Type = "month"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Type Type
Form string
Placeholder string
Value string
Disabled bool
Readonly bool
FileAccept string
HasError bool
NoTogglePassword bool
}
templ Input(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = TypeText }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<div class="relative w-full">
<input
id={ p.ID }
type={ string(p.Type) }
if p.Name != "" {
name={ p.Name }
}
if p.Placeholder != "" {
placeholder={ p.Placeholder }
}
if p.Value != "" {
value={ p.Value }
}
if p.Type == TypeFile && p.FileAccept != "" {
accept={ p.FileAccept }
}
if p.Form != "" {
form={ p.Form }
}
disabled?={ p.Disabled }
readonly?={ p.Readonly }
if p.HasError {
aria-invalid="true"
}
class={
utils.TwMerge(
// Base styles
"flex h-9 w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Placeholder
"placeholder:text-muted-foreground",
// File input styles
"file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Disabled styles
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
utils.If(p.Type == TypePassword && !p.NoTogglePassword, "pr-8"),
p.Class,
),
}
{ p.Attributes... }
/>
if p.Type == TypePassword && !p.NoTogglePassword {
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Class: "absolute right-0 top-1/2 -translate-y-1/2 opacity-50 cursor-pointer",
Attributes: templ.Attributes{"data-tui-input-toggle-password": p.ID},
}) {
<span class="icon-open block">
@icon.Eye(icon.Props{
Size: 18,
})
</span>
<span class="icon-closed hidden">
@icon.EyeOff(icon.Props{
Size: 18,
})
</span>
}
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/input.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,181 @@
// templui component inputotp - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/input-otp
package inputotp
import (
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Value string
Name string
Form string
HasError bool
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type SlotProps struct {
ID string
Class string
Attributes templ.Attributes
Index int
Type string
Placeholder string
Disabled bool
HasError bool
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ InputOTP(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID + "-container" }
}
if p.Value != "" {
data-tui-inputotp-value={ p.Value }
}
if p.Form != "" {
form={ p.Form }
}
class={
utils.TwMerge(
"flex flex-row items-center gap-2 w-fit",
p.Class,
),
}
data-tui-inputotp
{ p.Attributes... }
>
<input
type="hidden"
if p.ID != "" {
id={ p.ID }
}
if p.Name != "" {
name={ p.Name }
}
if p.HasError {
aria-invalid="true"
}
data-tui-inputotp-value-target
/>
{ children... }
</div>
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex gap-2",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</div>
}
templ Slot(props ...SlotProps) {
{{ var p SlotProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Type == "" {
{{ p.Type = "text" }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class="relative"
{ p.Attributes... }
>
<input
type={ p.Type }
inputmode="numeric"
if p.Placeholder != "" {
placeholder={ p.Placeholder }
}
maxlength="1"
class={
utils.TwMerge(
// Base styles - keeping the specific OTP dimensions
"w-10 h-12 text-center rounded-md border border-input bg-transparent text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Placeholder
"placeholder:text-muted-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Disabled styles
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
p.Class,
),
}
disabled?={ p.Disabled }
if p.HasError {
aria-invalid="true"
}
data-tui-inputotp-index={ strconv.Itoa(p.Index) }
data-tui-inputotp-slot
{ p.Attributes... }
/>
</div>
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex items-center text-muted-foreground text-xl",
p.Class,
),
}
{ p.Attributes... }
>
<span>-</span>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/inputotp.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,43 @@
// templui component label - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/label
package label
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
For string
Error string
}
templ Label(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<label
if p.ID != "" {
id={ p.ID }
}
if p.For != "" {
for={ p.For }
}
class={
utils.TwMerge(
"text-sm font-medium leading-none inline-block",
utils.If(len(p.Error) > 0, "text-destructive"),
p.Class,
),
}
data-tui-label-disabled-style="opacity-50 cursor-not-allowed"
{ p.Attributes... }
>
{ children... }
</label>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/label.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,250 @@
// templui component pagination - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/pagination
package pagination
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type LinkProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
IsActive bool
Disabled bool
}
type PreviousProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
Disabled bool
Label string
}
type NextProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
Disabled bool
Label string
}
templ Pagination(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<nav
if p.ID != "" {
id={ p.ID }
}
role="navigation"
aria-label="pagination"
class={ utils.TwMerge("flex flex-wrap justify-center", p.Class) }
{ p.Attributes... }
>
{ children... }
</nav>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<ul
if p.ID != "" {
id={ p.ID }
}
class="flex flex-row items-center gap-1"
{ p.Attributes... }
>
{ children... }
</ul>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<li
if p.ID != "" {
id={ p.ID }
}
{ p.Attributes... }
>
{ children... }
</li>
}
templ Link(props ...LinkProps) {
{{ var p LinkProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Disabled {
@button.Button(button.Props{
ID: p.ID,
Disabled: true,
Size: button.SizeIcon,
Variant: button.VariantGhost,
Class: p.Class,
Attributes: p.Attributes,
}) {
{ children... }
}
} else {
@button.Button(button.Props{
ID: p.ID,
Href: p.Href,
Size: button.SizeIcon,
Variant: button.Variant(buttonVariant(p.IsActive)),
Class: p.Class,
Attributes: p.Attributes,
}) {
{ children... }
}
}
}
templ Previous(props ...PreviousProps) {
{{ var p PreviousProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@button.Button(button.Props{
ID: p.ID,
Href: p.Href,
Disabled: p.Disabled,
Variant: button.VariantGhost,
Class: utils.TwMerge("gap-1", p.Class),
Attributes: p.Attributes,
}) {
@icon.ChevronLeft(icon.Props{Size: 16})
if p.Label != "" {
<span>{ p.Label }</span>
}
}
}
templ Next(props ...NextProps) {
{{ var p NextProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@button.Button(button.Props{
ID: p.ID,
Href: p.Href,
Disabled: p.Disabled,
Variant: button.VariantGhost,
Class: utils.TwMerge("gap-1", p.Class),
Attributes: p.Attributes,
}) {
if p.Label != "" {
<span>{ p.Label }</span>
}
@icon.ChevronRight(icon.Props{Size: 16})
}
}
templ Ellipsis() {
@icon.Ellipsis(icon.Props{Size: 16})
}
func CreatePagination(currentPage, totalPages, maxVisible int) struct {
CurrentPage int
TotalPages int
Pages []int
HasPrevious bool
HasNext bool
} {
if currentPage < 1 {
currentPage = 1
}
if totalPages < 1 {
totalPages = 1
}
if currentPage > totalPages {
currentPage = totalPages
}
if maxVisible < 1 {
maxVisible = 5
}
start, end := calculateVisibleRange(currentPage, totalPages, maxVisible)
pages := make([]int, 0, end-start+1)
for i := start; i <= end; i++ {
pages = append(pages, i)
}
return struct {
CurrentPage int
TotalPages int
Pages []int
HasPrevious bool
HasNext bool
}{
CurrentPage: currentPage,
TotalPages: totalPages,
Pages: pages,
HasPrevious: currentPage > 1,
HasNext: currentPage < totalPages,
}
}
func calculateVisibleRange(currentPage, totalPages, maxVisible int) (int, int) {
if totalPages <= maxVisible {
return 1, totalPages
}
half := maxVisible / 2
start := currentPage - half
end := currentPage + half
if start < 1 {
end += (1 - start)
start = 1
}
if end > totalPages {
start -= (end - totalPages)
if start < 1 {
start = 1
}
end = totalPages
}
return start, end
}
func buttonVariant(isActive bool) button.Variant {
if isActive {
return button.VariantOutline
}
return button.VariantGhost
}

View file

@ -0,0 +1,135 @@
// templui component popover - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/popover
package popover
import (
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Placement string
const (
PlacementTop Placement = "top"
PlacementTopStart Placement = "top-start"
PlacementTopEnd Placement = "top-end"
PlacementRight Placement = "right"
PlacementRightStart Placement = "right-start"
PlacementRightEnd Placement = "right-end"
PlacementBottom Placement = "bottom"
PlacementBottomStart Placement = "bottom-start"
PlacementBottomEnd Placement = "bottom-end"
PlacementLeft Placement = "left"
PlacementLeftStart Placement = "left-start"
PlacementLeftEnd Placement = "left-end"
)
type TriggerType string
const (
TriggerTypeHover TriggerType = "hover"
TriggerTypeClick TriggerType = "click"
)
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
For string
TriggerType TriggerType
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
Placement Placement
Offset int
DisableClickAway bool
DisableESC bool
ShowArrow bool
HoverDelay int
HoverOutDelay int
MatchWidth bool
Exclusive bool
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.TriggerType == "" {
{{ p.TriggerType = TriggerTypeClick }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("group cursor-pointer", p.Class) }
if p.For != "" {
data-tui-popover-trigger={ p.For }
}
data-tui-popover-open="false"
data-tui-popover-type={ string(p.TriggerType) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Placement == "" {
{{ p.Placement = PlacementBottom }}
}
if p.Offset == 0 {
if p.ShowArrow {
{{ p.Offset = 8 }}
} else {
{{ p.Offset = 4 }}
}
}
<div
id={ p.ID }
data-tui-popover-id={ p.ID }
data-tui-popover-open="false"
data-tui-popover-placement={ string(p.Placement) }
data-tui-popover-offset={ strconv.Itoa(p.Offset) }
data-tui-popover-disable-clickaway={ strconv.FormatBool(p.DisableClickAway) }
data-tui-popover-disable-esc={ strconv.FormatBool(p.DisableESC) }
data-tui-popover-show-arrow={ strconv.FormatBool(p.ShowArrow) }
data-tui-popover-hover-delay={ strconv.Itoa(p.HoverDelay) }
data-tui-popover-hover-out-delay={ strconv.Itoa(p.HoverOutDelay) }
data-tui-popover-exclusive={ strconv.FormatBool(p.Exclusive) }
if p.MatchWidth {
data-tui-popover-match-width="true"
}
class={ utils.TwMerge(
"bg-popover rounded-lg border text-popover-foreground text-sm shadow-lg pointer-events-auto absolute z-[9999] hidden top-0 left-0",
p.Class,
) }
{ p.Attributes... }
>
<div class="w-full overflow-hidden">
{ children... }
</div>
if p.ShowArrow {
<div
data-tui-popover-arrow
class="absolute h-2.5 w-2.5 rotate-45 bg-popover border border-border
data-[tui-popover-placement^=top]:-bottom-[5px] data-[tui-popover-placement^=top]:border-t-transparent data-[tui-popover-placement^=top]:border-l-transparent
data-[tui-popover-placement^=bottom]:-top-[5px] data-[tui-popover-placement^=bottom]:border-b-transparent data-[tui-popover-placement^=bottom]:border-r-transparent
data-[tui-popover-placement^=left]:-right-[5px] data-[tui-popover-placement^=left]:border-l-transparent data-[tui-popover-placement^=left]:border-b-transparent
data-[tui-popover-placement^=right]:-left-[5px] data-[tui-popover-placement^=right]:border-r-transparent data-[tui-popover-placement^=right]:border-t-transparent"
></div>
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/popover.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,127 @@
// templui component progress - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/progress
package progress
import (
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Size string
type Variant string
const (
SizeSm Size = "sm"
SizeLg Size = "lg"
)
const (
VariantDefault Variant = "default"
VariantSuccess Variant = "success"
VariantDanger Variant = "danger"
VariantWarning Variant = "warning"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Max int
Value int
Label string
ShowValue bool
Size Size
Variant Variant
BarClass string
}
templ Progress(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<div
id={ p.ID }
class={ utils.TwMerge("w-full", p.Class) }
aria-valuemin="0"
aria-valuemax={ fmt.Sprintf("%d", maxValue(p.Max)) }
aria-valuenow={ fmt.Sprintf("%d", p.Value) }
role="progressbar"
{ p.Attributes... }
>
if p.Label != "" || p.ShowValue {
<div class="flex justify-between items-center mb-1">
if p.Label != "" {
<span class="text-sm font-medium">{ p.Label }</span>
}
if p.ShowValue {
<span class="text-sm font-medium">
{ fmt.Sprintf("%d%%", percentage(p.Value, p)) }
</span>
}
</div>
}
<div class="w-full overflow-hidden rounded-full bg-secondary">
<div
data-tui-progress-indicator
class={
utils.TwMerge(
"h-full rounded-full transition-all",
sizeClasses(p.Size),
variantClasses(p.Variant),
p.BarClass,
),
}
></div>
</div>
</div>
}
func maxValue(max int) int {
if max <= 0 {
return 100
}
return max
}
func percentage(value int, props Props) int {
max := maxValue(props.Max)
if value < 0 {
value = 0
}
if value > max {
value = max
}
return (value * 100) / max
}
func sizeClasses(size Size) string {
switch size {
case SizeSm:
return "h-1"
case SizeLg:
return "h-4"
default:
return "h-2.5"
}
}
func variantClasses(variant Variant) string {
switch variant {
case VariantSuccess:
return "bg-green-500"
case VariantDanger:
return "bg-destructive"
case VariantWarning:
return "bg-yellow-500"
default:
return "bg-primary"
}
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/progress.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,57 @@
// templui component radio - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/radio
package radio
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Value string
Form string
Disabled bool
Checked bool
}
templ Radio(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<input
type="radio"
if p.ID != "" {
id={ p.ID }
}
if p.Name != "" {
name={ p.Name }
}
if p.Value != "" {
value={ p.Value }
}
if p.Form != "" {
form={ p.Form }
}
checked?={ p.Checked }
disabled?={ p.Disabled }
class={
utils.TwMerge(
"relative h-4 w-4",
"before:absolute before:left-1/2 before:top-1/2",
"before:h-1.5 before:w-1.5 before:-translate-x-1/2 before:-translate-y-1/2",
"appearance-none rounded-full",
"border-2 border-primary",
"before:content[''] before:rounded-full before:bg-background",
"checked:border-primary checked:bg-primary",
"checked:before:visible",
"focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring",
"focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed",
p.Class,
),
}
{ p.Attributes... }
/>
}

View file

@ -0,0 +1,193 @@
// templui component rating - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/rating
package rating
import (
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Style string
const (
StyleStar Style = "star"
StyleHeart Style = "heart"
StyleEmoji Style = "emoji"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Value float64
ReadOnly bool
Precision float64
Name string
Form string
OnlyInteger bool
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value int
Style Style
}
templ Rating(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
<div
if p.ID != "" {
id={ p.ID }
}
data-tui-rating-component
data-tui-rating-initial-value={ fmt.Sprintf("%.2f", p.Value) }
data-tui-rating-precision={ fmt.Sprintf("%.2f", p.Precision) }
data-tui-rating-readonly={ strconv.FormatBool(p.ReadOnly) }
if p.Name != "" {
data-tui-rating-name={ p.Name }
}
data-tui-rating-onlyinteger={ strconv.FormatBool(p.OnlyInteger) }
class={
utils.TwMerge(
"flex flex-col items-start gap-1",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
if p.Name != "" {
<input
type="hidden"
name={ p.Name }
value={ fmt.Sprintf("%.2f", p.Value) }
if p.Form != "" {
form={ p.Form }
}
data-tui-rating-input
/>
}
</div>
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex flex-row items-center gap-1", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ p.setDefaults() }}
<div
if p.ID != "" {
id={ p.ID }
}
data-tui-rating-item
data-tui-rating-value={ strconv.Itoa(p.Value) }
class={
utils.TwMerge(
"relative",
colorClass(p.Style),
"transition-opacity",
"cursor-pointer", // Default cursor
p.Class,
),
}
{ p.Attributes... }
>
<div class="opacity-30">
@ratingIcon(p.Style, false, float64(p.Value))
</div>
<div
class="absolute inset-0 overflow-hidden w-0"
data-tui-rating-item-foreground
>
@ratingIcon(p.Style, true, float64(p.Value))
</div>
</div>
}
func colorClass(style Style) string {
switch style {
case StyleHeart:
return "text-destructive"
case StyleEmoji:
return "text-yellow-500"
default:
return "text-yellow-400"
}
}
func ratingIcon(style Style, filled bool, value float64) templ.Component {
if style == StyleEmoji {
if filled {
switch {
case value <= 1:
return icon.Angry()
case value <= 2:
return icon.Frown()
case value <= 3:
return icon.Meh()
case value <= 4:
return icon.Smile()
default:
return icon.Laugh()
}
}
return icon.Meh()
}
iconProps := icon.Props{}
if filled {
iconProps.Fill = "currentColor"
}
switch style {
case StyleHeart:
return icon.Heart(iconProps)
default:
return icon.Star(iconProps)
}
}
func (p *ItemProps) setDefaults() {
if p.Style == "" {
p.Style = StyleStar
}
}
func (p *Props) setDefaults() {
if p.Precision <= 0 {
p.Precision = 1.0
}
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/rating.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,325 @@
// templui component selectbox - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/select-box
package selectbox
import (
"context"
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type contextKey string
var contentIDKey contextKey = "contentID"
type Props struct {
ID string
Class string
Attributes templ.Attributes
Multiple bool
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Form string
Disabled bool
HasError bool
Multiple bool
ShowPills bool
SelectedCountText string
}
type ValueProps struct {
ID string
Class string
Attributes templ.Attributes
Placeholder string
Multiple bool
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
NoSearch bool
SearchPlaceholder string
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type LabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ItemProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
Selected bool
Disabled bool
}
templ SelectBox(props ...Props) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
wrapperID := p.ID
if wrapperID == "" {
wrapperID = utils.RandomID()
}
contentID := fmt.Sprintf("%s-content", wrapperID)
ctx = context.WithValue(ctx, contentIDKey, contentID)
}}
<div
id={ wrapperID }
class={ utils.TwMerge("select-container w-full relative", p.Class) }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Trigger(props ...TriggerProps) {
{{
var p TriggerProps
if len(props) > 0 {
p = props[0]
}
contentID, ok := ctx.Value(contentIDKey).(string)
if !ok {
contentID = "fallback-select-content-id"
}
if p.ShowPills {
p.Multiple = true
}
}}
@popover.Trigger(popover.TriggerProps{
For: contentID,
TriggerType: popover.TriggerTypeClick,
}) {
@button.Button(button.Props{
ID: p.ID,
Type: "button",
Variant: button.VariantOutline,
Class: utils.TwMerge(
// Required class for JavaScript
"select-trigger",
// Base styles matching input
"w-full h-9 px-3 py-1 text-base md:text-sm",
"flex items-center justify-between",
"rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
p.Class,
),
Disabled: p.Disabled,
Attributes: utils.MergeAttributes(
templ.Attributes{
"data-tui-selectbox-content-id": contentID,
"data-tui-selectbox-multiple": strconv.FormatBool(p.Multiple),
"data-tui-selectbox-show-pills": strconv.FormatBool(p.ShowPills),
"data-tui-selectbox-selected-count-text": p.SelectedCountText,
"tabindex": "0",
"aria-invalid": utils.If(p.HasError, "true"),
},
),
}) {
<input
type="hidden"
if p.Name != "" {
name={ p.Name }
}
if p.Form != "" {
form={ p.Form }
}
{ p.Attributes... }
/>
{ children... }
<span class="pointer-events-none ml-1">
@icon.ChevronDown(icon.Props{
Size: 16,
Class: "text-muted-foreground",
})
</span>
}
}
}
templ Value(props ...ValueProps) {
{{ var p ValueProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("block truncate select-value text-muted-foreground", p.Class) }
if p.Placeholder != "" {
data-tui-selectbox-placeholder={ p.Placeholder }
}
{ p.Attributes... }
>
if p.Placeholder != "" {
{ p.Placeholder }
}
{ children... }
</span>
}
templ Content(props ...ContentProps) {
{{
var p ContentProps
if len(props) > 0 {
p = props[0]
}
contentID, ok := ctx.Value(contentIDKey).(string)
if !ok {
contentID = "fallback-select-content-id"
}
}}
@popover.Content(popover.ContentProps{
ID: contentID,
Placement: popover.PlacementBottomStart,
Offset: 4,
MatchWidth: true,
DisableESC: !p.NoSearch,
Class: utils.TwMerge(
"p-1 select-content z-50 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md",
"min-w-[var(--popover-trigger-width)] w-[var(--popover-trigger-width)]",
p.Class,
),
Attributes: utils.MergeAttributes(
templ.Attributes{
"role": "listbox",
"tabindex": "-1",
},
p.Attributes,
),
Exclusive: true,
}) {
if !p.NoSearch {
<div class="sticky top-0 bg-popover p-1">
<div class="relative">
<span class="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground z-10 pointer-events-none">
@icon.Search(icon.Props{Size: 16})
</span>
@input.Input(input.Props{
Type: input.TypeSearch,
Class: "pl-8",
Placeholder: utils.IfElse(p.SearchPlaceholder != "", p.SearchPlaceholder, "Search..."),
Attributes: templ.Attributes{
"data-tui-selectbox-search": "",
},
})
</div>
</div>
}
<div class="max-h-[300px] overflow-y-auto">
{ children... }
</div>
}
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("p-1", p.Class) }
role="group"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Label(props ...LabelProps) {
{{ var p LabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("px-2 py-1.5 text-sm font-medium", p.Class) }
{ p.Attributes... }
>
{ children... }
</span>
}
templ Item(props ...ItemProps) {
{{ var p ItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"select-item relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 px-2 text-sm font-light outline-none",
"hover:bg-accent hover:text-accent-foreground",
"focus:bg-accent focus:text-accent-foreground",
utils.If(p.Selected, "bg-accent text-accent-foreground"),
utils.If(p.Disabled, "pointer-events-none opacity-50"),
p.Class,
),
}
role="option"
data-tui-selectbox-value={ p.Value }
data-tui-selectbox-selected={ strconv.FormatBool(p.Selected) }
data-tui-selectbox-disabled={ strconv.FormatBool(p.Disabled) }
tabindex="0"
{ p.Attributes... }
>
<span class="truncate select-item-text">
{ children... }
</span>
<span
class={
utils.TwMerge(
"select-check absolute right-2 flex h-3.5 w-3.5 items-center justify-center",
utils.IfElse(p.Selected, "opacity-100", "opacity-0"),
),
}
>
@icon.Check(icon.Props{Size: 16})
</span>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/selectbox.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,98 @@
// templui component separator - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/separator
package separator
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Orientation string
type Decoration string
const (
OrientationHorizontal Orientation = "horizontal"
OrientationVertical Orientation = "vertical"
)
const (
DecorationDashed Decoration = "dashed"
DecorationDotted Decoration = "dotted"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Orientation Orientation
Decoration Decoration
}
templ Separator(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Orientation == "" {
{{ p.Orientation = OrientationHorizontal }}
}
if p.Orientation == OrientationHorizontal {
<div
if p.ID != "" {
id={ p.ID }
}
role="separator"
aria-orientation="horizontal"
class={ utils.TwMerge("shrink-0 w-full", p.Class) }
{ p.Attributes... }
>
<div class="relative flex items-center w-full">
<span
class={
utils.TwMerge(
"absolute w-full border-t h-[1px]",
decorationClasses(p.Decoration),
),
}
aria-hidden="true"
></span>
<span class="relative mx-auto bg-background px-2 text-xs text-muted-foreground">
{ children... }
</span>
</div>
</div>
} else {
<div
if p.ID != "" {
id={ p.ID }
}
role="separator"
aria-orientation="vertical"
class={ utils.TwMerge("shrink-0 h-full", p.Class) }
{ p.Attributes... }
>
<div class="relative flex flex-col items-center h-full">
<span
class={
utils.TwMerge(
"absolute h-full border-l w-[1px]",
decorationClasses(p.Decoration),
),
}
aria-hidden="true"
></span>
<span class="relative my-auto bg-background py-2 text-xs text-muted-foreground">
{ children... }
</span>
</div>
</div>
}
}
func decorationClasses(decoration Decoration) string {
switch decoration {
case DecorationDashed:
return "border-dashed"
case DecorationDotted:
return "border-dotted"
default:
return ""
}
}

View file

@ -0,0 +1,318 @@
// templui component sheet - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/sheet
package sheet
import (
"context"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/dialog"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type contextKey string
const (
sideKey contextKey = "sheetSide"
)
type Side string
const (
SideTop Side = "top"
SideRight Side = "right"
SideBottom Side = "bottom"
SideLeft Side = "left"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Side Side
Open bool
DisableClickAway bool
DisableESC bool
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
For string // Reference to a specific sheet ID (for external triggers)
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
HideCloseButton bool
Side Side //
Open bool // Initial open state for standalone usage
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TitleProps struct {
ID string
Class string
Attributes templ.Attributes
}
type DescriptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
type CloseProps struct {
ID string
Class string
Attributes templ.Attributes
For string
}
templ Sheet(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Side == "" {
{{ p.Side = SideRight }}
}
// Pass the Side through context to child components
{{ ctx = context.WithValue(ctx, sideKey, p.Side) }}
// Sheet uses Dialog internally with sheet-specific attributes
@dialog.Dialog(dialog.Props{
ID: p.ID,
Open: p.Open,
DisableClickAway: p.DisableClickAway,
DisableESC: p.DisableESC,
Class: p.Class,
Attributes: utils.MergeAttributes(
templ.Attributes{
"data-tui-sheet": "true",
"data-tui-sheet-side": string(p.Side),
},
p.Attributes,
),
}) {
{ children... }
}
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet trigger is just a Dialog trigger
@dialog.Trigger(dialog.TriggerProps{
ID: p.ID,
For: p.For,
Class: p.Class,
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Get Side from context if not explicitly provided
if p.Side == "" {
if val := ctx.Value(sideKey); val != nil {
{{ p.Side = val.(Side) }}
} else {
{{ p.Side = SideRight }}
}
}
// Sheet content uses Dialog content with sheet-specific styles
@dialog.Content(dialog.ContentProps{
ID: p.ID,
Open: p.Open,
HideCloseButton: p.HideCloseButton,
Class: utils.TwMerge(
// First apply side-specific positioning and animations
getSideClasses(p.Side),
// Default gap matching shadcn (no padding in content)
"gap-4 !p-0", // Remove Dialog's p-6 padding
// Override Dialog styles
"!scale-100", // Reset Dialog's scale animation
"!rounded-none", // Remove dialog rounded corners
"!opacity-100", // Keep fully opaque - no fade, only slide
// Remove pointer-events control during animation
"!pointer-events-auto data-[tui-dialog-hidden=true]:!pointer-events-none",
// User-provided classes last
p.Class,
),
Attributes: utils.MergeAttributes(
templ.Attributes{
"data-tui-sheet-content": "true",
"data-tui-sheet-side": string(p.Side),
},
p.Attributes,
),
}) {
{ children... }
}
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet header uses Dialog header but overrides styles
@dialog.Header(dialog.HeaderProps{
ID: p.ID,
Class: utils.TwMerge("gap-1.5 p-4 text-left", p.Class),
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Title(props ...TitleProps) {
{{ var p TitleProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet title uses Dialog title but overrides styles
@dialog.Title(dialog.TitleProps{
ID: p.ID,
Class: utils.TwMerge("text-base leading-normal", p.Class),
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Description(props ...DescriptionProps) {
{{ var p DescriptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet description uses Dialog description
@dialog.Description(dialog.DescriptionProps{
ID: p.ID,
Class: p.Class,
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet footer uses Dialog footer but overrides styles
@dialog.Footer(dialog.FooterProps{
ID: p.ID,
Class: utils.TwMerge("mt-auto flex flex-col gap-2 p-4", p.Class),
Attributes: p.Attributes,
}) {
{ children... }
}
}
templ Close(props ...CloseProps) {
{{ var p CloseProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Sheet close uses Dialog close
@dialog.Close(dialog.CloseProps{
ID: p.ID,
For: p.For,
Class: p.Class,
Attributes: p.Attributes,
}) {
{ children... }
}
}
func getSideClasses(side Side) string {
// Base classes for all sheets - matching shadcn
// Duration varies: 300ms when closing, 500ms when opening
// Use !transition-transform to override Dialog's transition-all
baseClasses := "fixed z-50 flex flex-col bg-background shadow-lg !transition-transform ease-in-out " +
"data-[tui-dialog-open=false]:duration-300 data-[tui-dialog-open=true]:duration-500 "
switch side {
case SideRight:
return baseClasses +
// Positioning
"!inset-y-0 !right-0 !left-auto !top-auto " +
// Size
"h-full w-3/4 sm:max-w-sm " +
// Border
"border-l border-t-0 border-r-0 border-b-0 " +
// Reset Dialog transforms
"!translate-y-0 " +
// Slide animation
"data-[tui-dialog-open=false]:!translate-x-full " +
"data-[tui-dialog-open=true]:!translate-x-0"
case SideLeft:
return baseClasses +
// Positioning
"!inset-y-0 !left-0 !right-auto !top-auto " +
// Size
"h-full w-3/4 sm:max-w-sm " +
// Border
"border-r border-t-0 border-l-0 border-b-0 " +
// Reset Dialog transforms
"!translate-y-0 " +
// Slide animation
"data-[tui-dialog-open=false]:!-translate-x-full " +
"data-[tui-dialog-open=true]:!translate-x-0"
case SideTop:
return baseClasses +
// Positioning - full width at top
"!inset-x-0 !top-0 !bottom-auto !left-0 !right-0 " +
// Size - full width, auto height
"!w-full !max-w-full h-auto " +
// Border
"border-b border-t-0 border-l-0 border-r-0 " +
// Reset Dialog transforms - IMPORTANT: reset the centering from Dialog
"!translate-x-0 !translate-y-0 " +
// Slide animation - top slides up when closing
"data-[tui-dialog-open=false]:!-translate-y-full " +
"data-[tui-dialog-open=true]:!translate-y-0"
case SideBottom:
return baseClasses +
// Positioning - full width at bottom
"!inset-x-0 !bottom-0 !top-auto !left-0 !right-0 " +
// Size - full width, auto height
"!w-full !max-w-full h-auto " +
// Border
"border-t border-b-0 border-l-0 border-r-0 " +
// Reset Dialog transforms - IMPORTANT: reset the centering from Dialog
"!translate-x-0 !translate-y-0 " +
// Slide animation
"data-[tui-dialog-open=false]:!translate-y-full " +
"data-[tui-dialog-open=true]:!translate-y-0"
default:
return baseClasses +
// Default to right side
"!inset-y-0 !right-0 !left-auto !top-auto " +
"h-full w-3/4 " +
"border-l border-t-0 border-r-0 border-b-0 " +
"!translate-y-0 " +
"data-[tui-dialog-open=false]:!translate-x-full " +
"data-[tui-dialog-open=true]:!translate-x-0"
}
}

View file

@ -0,0 +1,753 @@
// templui component sidebar - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/sidebar
package sidebar
import "context"
import "git.juancwu.dev/juancwu/budgething/internal/utils"
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/sheet"
import "git.juancwu.dev/juancwu/budgething/internal/ui/components/tooltip"
type contextKey string
const sidebarIDKey contextKey = "sidebar-id"
type Side string
const (
SideLeft Side = "left" // default
SideRight Side = "right"
)
type Variant string
const (
VariantSidebar Variant = "sidebar" // default
VariantFloating Variant = "floating"
VariantInset Variant = "inset"
)
type Collapsible string
const (
CollapsibleOffcanvas Collapsible = "offcanvas" // default
CollapsibleIcon Collapsible = "icon"
CollapsibleNone Collapsible = "none"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Side Side // default: "left"
Variant Variant // default: "sidebar"
Collapsible Collapsible // default: "offcanvas"
Collapsed bool // default: false (sidebar open)
KeyboardShortcut string // default: "b"
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Target string // Target sidebar ID to toggle
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
type InsetProps struct {
ID string
Class string
Attributes templ.Attributes
}
type GroupProps struct {
ID string
Class string
Attributes templ.Attributes
}
type GroupLabelProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuButtonSize string
const (
MenuButtonSizeDefault MenuButtonSize = "default" // default
MenuButtonSizeSm MenuButtonSize = "sm"
MenuButtonSizeLg MenuButtonSize = "lg"
)
type MenuButtonProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
IsActive bool
Size MenuButtonSize // default: "default"
Tooltip string // Tooltip text to show when sidebar is collapsed
}
type MenuBadgeProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuSubProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuSubItemProps struct {
ID string
Class string
Attributes templ.Attributes
}
type MenuSubButtonProps struct {
ID string
Class string
Attributes templ.Attributes
Href string
IsActive bool
}
type SeparatorProps struct {
ID string
Class string
Attributes templ.Attributes
}
type LayoutProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Layout(props ...LayoutProps) {
{{ var p LayoutProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Generate ID for context (children components can use it for targeting)
{{ var sidebarId string = p.ID }}
if sidebarId == "" {
{{ sidebarId = utils.RandomID() }}
}
// Set sidebar ID in context for children to access
{{ ctx = context.WithValue(ctx, sidebarIDKey, sidebarId) }}
<!-- Layout Container - No ID needed here -->
<div
class={ utils.TwMerge(
"flex min-h-svh relative",
"has-[[data-tui-sidebar-variant=inset]]:bg-sidebar",
p.Class,
) }
data-tui-sidebar-layout
{ p.Attributes... }
>
{ children... }
</div>
}
templ Sidebar(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Get sidebar ID from context or use provided/generate new
if p.ID == "" {
if ctxId := ctx.Value(sidebarIDKey); ctxId != nil {
{{ p.ID = ctxId.(string) }}
} else {
{{ p.ID = utils.RandomID() }}
}
}
if p.Side == "" {
{{ p.Side = SideLeft }}
}
if p.Variant == "" {
{{ p.Variant = VariantSidebar }}
}
if p.Collapsible == "" {
{{ p.Collapsible = CollapsibleOffcanvas }}
}
if p.KeyboardShortcut == "" {
{{ p.KeyboardShortcut = "b" }}
}
// Use the sidebar's ID for mobile sheet and trigger targeting
{{ var sidebarId string = p.ID }}
{{ sidebarState := "expanded" }}
if p.Collapsed {
{{ sidebarState = "collapsed" }}
}
<!-- Mobile: Sheet Component for < 768px -->
{{ var sheetSide sheet.Side }}
if p.Side == SideRight {
{{ sheetSide = sheet.SideRight }}
} else {
{{ sheetSide = sheet.SideLeft }}
}
<!-- Mobile sheet wrapper without content -->
@sheet.Sheet(sheet.Props{
ID: sidebarId + "-mobile",
Side: sheetSide,
Open: false,
}) {
@sheet.Content(sheet.ContentProps{
Class: "md:hidden bg-sidebar text-sidebar-foreground !p-0 !gap-0 flex flex-col h-full",
HideCloseButton: true,
}) {
<!-- Mobile content wrapper: visible only on mobile via JS -->
<div class="sidebar-mobile-portal flex h-full flex-col" data-tui-sidebar-mobile-portal={ sidebarId }></div>
}
}
<!-- Desktop: Sidebar for >= 768px -->
<div
class={ utils.TwMerge(
"group peer hidden md:block",
p.Class,
) }
data-tui-sidebar-state={ sidebarState }
data-tui-sidebar-collapsible={ string(p.Collapsible) }
data-tui-sidebar-variant={ string(p.Variant) }
data-tui-sidebar-side={ string(p.Side) }
data-tui-sidebar-wrapper
data-tui-sidebar-id={ p.ID }
data-tui-sidebar-keyboard-shortcut={ p.KeyboardShortcut }
{ utils.MergeAttributes(
templ.Attributes{"style": "--sidebar-width:16rem"},
p.Attributes,
)... }
>
<!-- Gap element for document flow -->
<div
class={ utils.TwMerge(
"relative bg-transparent transition-[width] duration-200 ease-linear",
"w-[var(--sidebar-width,16rem)]",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=offcanvas]:w-0",
"group-data-[tui-sidebar-side=right]:rotate-180",
// Add padding for floating/inset variants when collapsed to icon mode
"group-data-[tui-sidebar-variant=floating]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-[calc(3rem+theme(spacing.4))]",
"group-data-[tui-sidebar-variant=inset]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-[calc(3rem+theme(spacing.4))]",
"group-data-[tui-sidebar-variant=sidebar]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-12",
) }
></div>
<!-- Sidebar Container -->
<aside
id={ p.ID }
class={ utils.TwMerge(
"fixed inset-y-0 z-10 hidden h-svh transition-transform duration-200 ease-linear md:flex",
"w-[var(--sidebar-width,16rem)]",
// Side positioning with data attributes
"group-data-[tui-sidebar-side=right]:right-0 group-data-[tui-sidebar-side=right]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=offcanvas]:translate-x-full",
"group-data-[tui-sidebar-side=left]:left-0 group-data-[tui-sidebar-side=left]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=offcanvas]:-translate-x-full",
// Adjust padding and width for variants
"group-data-[tui-sidebar-variant=floating]:p-2 group-data-[tui-sidebar-variant=floating]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-[calc(3rem+(theme(spacing.4))+2px)]",
"group-data-[tui-sidebar-variant=inset]:p-2 group-data-[tui-sidebar-variant=inset]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-[calc(3rem+(theme(spacing.4))+2px)]",
"group-data-[tui-sidebar-variant=sidebar]:group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:w-12",
"group-data-[tui-sidebar-variant=sidebar]:group-data-[tui-sidebar-side=left]:border-r group-data-[tui-sidebar-variant=sidebar]:group-data-[tui-sidebar-side=right]:border-l",
p.Class,
) }
data-sidebar="sidebar"
>
<!-- Inner sidebar with variant-specific styling -->
<div
data-sidebar="sidebar"
class={ utils.TwMerge(
"bg-sidebar group-data-[tui-sidebar-variant=floating]:border-sidebar-border flex h-full w-full flex-col",
"group-data-[tui-sidebar-variant=floating]:rounded-lg group-data-[tui-sidebar-variant=floating]:border group-data-[tui-sidebar-variant=floating]:shadow-sm",
) }
>
<!-- Main content: rendered once, shown conditionally -->
<div data-tui-sidebar-content={ sidebarId } class="flex h-full w-full flex-col sidebar-content">
{ children... }
</div>
</div>
</aside>
</div>
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
// Get sidebar ID from: 1. Target prop, 2. Context
{{ var sidebarId string }}
if p.Target != "" {
{{ sidebarId = p.Target }}
} else if ctxId := ctx.Value(sidebarIDKey); ctxId != nil {
{{ sidebarId = ctxId.(string) }}
}
<!-- Mobile: Trigger to open the sheet created in Sidebar component -->
@sheet.Trigger(sheet.TriggerProps{
For: sidebarId + "-mobile",
Class: "md:hidden",
}) {
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Class: "size-7",
}) {
@icon.PanelLeft(icon.Props{Class: "size-4"})
<span class="sr-only">Toggle Sidebar</span>
}
}
<!-- Desktop: Sidebar Trigger -->
<div class="hidden md:block">
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Class: utils.TwMerge(
"size-7",
p.Class,
),
Attributes: utils.MergeAttributes(
templ.Attributes{
"data-tui-sidebar-trigger": true,
"data-tui-sidebar-target": sidebarId,
},
p.Attributes,
)},
) {
@icon.PanelLeft(icon.Props{Class: "size-4"})
<span class="sr-only">Toggle Sidebar</span>
}
</div>
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex flex-col gap-2 p-2", p.Class) }
data-tui-sidebar="header"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("mt-auto flex flex-col gap-2 p-2", p.Class) }
data-tui-sidebar="footer"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"flex min-h-0 flex-1 flex-col gap-2 overflow-auto",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:overflow-hidden",
p.Class,
) }
data-tui-sidebar="content"
{ p.Attributes... }
>
{ children... }
</div>
}
templ Menu(props ...MenuProps) {
{{ var p MenuProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<ul
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex w-full min-w-0 flex-col gap-1", p.Class) }
data-tui-sidebar="menu"
{ p.Attributes... }
>
{ children... }
</ul>
}
templ MenuItem(props ...MenuItemProps) {
{{ var p MenuItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<li
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("group/menu-item relative", p.Class) }
data-tui-sidebar="menu-item"
{ p.Attributes... }
>
{ children... }
</li>
}
templ MenuButton(props ...MenuButtonProps) {
{{ var p MenuButtonProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Size == "" {
{{ p.Size = MenuButtonSizeDefault }}
}
if p.Tooltip != "" {
{{ tooltipID := utils.RandomID() }}
// When collapsed to icon mode - show with tooltip
<div class="group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:block hidden">
@tooltip.Tooltip() {
@tooltip.Trigger(tooltip.TriggerProps{
For: tooltipID,
}) {
@menuButtonContent(p, "") {
{ children... }
}
}
@tooltip.Content(tooltip.ContentProps{
ID: tooltipID,
Position: tooltip.PositionRight,
HoverDelay: 200,
HoverOutDelay: 100,
}) {
{ p.Tooltip }
}
}
</div>
// When expanded - show without tooltip
<div class="group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:hidden">
@menuButtonContent(p, "") {
{ children... }
}
</div>
} else {
@menuButtonContent(p, "") {
{ children... }
}
}
}
templ menuButtonContent(p MenuButtonProps, buttonID string) {
if p.Href != "" {
<a
if buttonID != "" {
id={ buttonID }
}
href={ templ.SafeURL(p.Href) }
class={ utils.TwMerge(
"flex w-full items-center gap-2 rounded-md p-2 text-left overflow-hidden",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"transition-[width,height,padding]",
// Size variants with data attributes
"data-[tui-sidebar-size=sm]:h-7 data-[tui-sidebar-size=sm]:text-xs",
"data-[tui-sidebar-size=lg]:h-12 data-[tui-sidebar-size=lg]:text-sm",
"data-[tui-sidebar-size=default]:h-8 data-[tui-sidebar-size=default]:text-sm",
// Active state with data attributes
"data-[tui-sidebar-active=true]:bg-sidebar-accent data-[tui-sidebar-active=true]:text-sidebar-accent-foreground data-[tui-sidebar-active=true]:font-medium",
// Collapsed icon mode styles - matching shadcn exactly
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:!size-8",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:!p-2",
// Override padding for lg size (avatars) in collapsed mode
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:data-[tui-sidebar-size=lg]:!p-0",
"[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
p.Class,
) }
data-tui-sidebar="menu-button"
data-tui-sidebar-size={ string(p.Size) }
if p.IsActive {
data-tui-sidebar-active="true"
}
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
if buttonID != "" {
id={ buttonID }
}
type="button"
class={ utils.TwMerge(
"flex w-full items-center gap-2 rounded-md p-2 text-left overflow-hidden",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"transition-[width,height,padding]",
// Size variants with data attributes
"data-[tui-sidebar-size=sm]:h-7 data-[tui-sidebar-size=sm]:text-xs",
"data-[tui-sidebar-size=lg]:h-12 data-[tui-sidebar-size=lg]:text-sm",
"data-[tui-sidebar-size=default]:h-8 data-[tui-sidebar-size=default]:text-sm",
// Active state with data attributes
"data-[tui-sidebar-active=true]:bg-sidebar-accent data-[tui-sidebar-active=true]:text-sidebar-accent-foreground data-[tui-sidebar-active=true]:font-medium",
// Collapsed icon mode styles - matching shadcn exactly
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:!size-8",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:!p-2",
// Override padding for lg size (avatars) in collapsed mode
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:data-[tui-sidebar-size=lg]:!p-0",
"[&>span:last-child]:truncate [&>svg]:size-4 [&>svg]:shrink-0",
p.Class,
) }
data-tui-sidebar="menu-button"
data-tui-sidebar-size={ string(p.Size) }
if p.IsActive {
data-tui-sidebar-active="true"
}
{ p.Attributes... }
>
{ children... }
</button>
}
}
templ MenuSub(props ...MenuSubProps) {
{{ var p MenuSubProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<ul
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l border-sidebar-border px-2.5 py-0.5",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:hidden",
p.Class,
) }
data-tui-sidebar="menu-sub"
{ p.Attributes... }
>
{ children... }
</ul>
}
templ MenuSubItem(props ...MenuSubItemProps) {
{{ var p MenuSubItemProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<li
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("group/menu-sub-item relative", p.Class) }
data-tui-sidebar="menu-sub-item"
{ p.Attributes... }
>
{ children... }
</li>
}
templ MenuSubButton(props ...MenuSubButtonProps) {
{{ var p MenuSubButtonProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.Href != "" {
<a
if p.ID != "" {
id={ p.ID }
}
href={ templ.SafeURL(p.Href) }
class={ utils.TwMerge(
"flex items-center gap-2 rounded-md px-2 py-1.5 text-sm",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"transition-colors",
"data-[tui-sidebar-active=true]:bg-sidebar-accent data-[tui-sidebar-active=true]:text-sidebar-accent-foreground data-[tui-sidebar-active=true]:font-medium",
p.Class,
) }
data-tui-sidebar="menu-sub-button"
if p.IsActive {
data-tui-sidebar-active="true"
}
{ p.Attributes... }
>
{ children... }
</a>
} else {
<button
if p.ID != "" {
id={ p.ID }
}
type="button"
class={ utils.TwMerge(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-left",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"transition-colors",
"data-[tui-sidebar-active=true]:bg-sidebar-accent data-[tui-sidebar-active=true]:text-sidebar-accent-foreground data-[tui-sidebar-active=true]:font-medium",
p.Class,
) }
data-tui-sidebar="menu-sub-button"
if p.IsActive {
data-tui-sidebar-active="true"
}
{ p.Attributes... }
>
{ children... }
</button>
}
}
templ Inset(props ...InsetProps) {
{{ var p InsetProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<main
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"relative flex w-full flex-1 flex-col bg-background",
// Add special styling when peer sidebar has variant="inset"
"md:peer-data-[tui-sidebar-variant=inset]:m-2",
"md:peer-data-[tui-sidebar-variant=inset]:ml-0",
"md:peer-data-[tui-sidebar-variant=inset]:rounded-xl",
"md:peer-data-[tui-sidebar-variant=inset]:shadow-sm",
// When sidebar is collapsed (offcanvas mode) and variant is inset, add left margin back
"md:peer-data-[tui-sidebar-variant=inset]:peer-data-[tui-sidebar-state=collapsed]:peer-data-[tui-sidebar-collapsible=offcanvas]:ml-2",
p.Class,
) }
data-tui-sidebar="inset"
{ p.Attributes... }
>
{ children... }
</main>
}
templ Group(props ...GroupProps) {
{{ var p GroupProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("relative flex w-full min-w-0 flex-col p-2", p.Class) }
data-tui-sidebar="group"
{ p.Attributes... }
>
{ children... }
</div>
}
templ GroupLabel(props ...GroupLabelProps) {
{{ var p GroupLabelProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium text-sidebar-foreground/70",
"ring-sidebar-ring outline-none transition-[margin,opacity] duration-200 ease-linear",
"focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:-mt-8 group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:opacity-0",
p.Class,
) }
data-tui-sidebar="group-label"
{ p.Attributes... }
>
{ children... }
</div>
}
templ MenuBadge(props ...MenuBadgeProps) {
{{ var p MenuBadgeProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<span
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"ml-auto flex h-5 min-w-5 items-center justify-center rounded-md px-1 text-xs font-medium",
"bg-sidebar-accent text-sidebar-accent-foreground",
"group-data-[tui-sidebar-state=collapsed]:group-data-[tui-sidebar-collapsible=icon]:hidden",
p.Class,
) }
data-tui-sidebar="menu-badge"
{ p.Attributes... }
>
{ children... }
</span>
}
templ Separator(props ...SeparatorProps) {
{{ var p SeparatorProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<hr
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge(
"mx-2 my-2 border-t border-sidebar-border",
p.Class,
) }
data-tui-sidebar="separator"
{ p.Attributes... }
/>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/sidebar.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,30 @@
// templui component skeleton - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/skeleton
package skeleton
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
templ Skeleton(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"animate-pulse rounded bg-muted",
p.Class,
),
}
{ p.Attributes... }
></div>
}

View file

@ -0,0 +1,121 @@
// templui component slider - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/slider
package slider
import (
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type InputProps struct {
ID string
Class string
Attributes templ.Attributes
Name string
Min int
Max int
Step int
Value int
Disabled bool
}
type ValueProps struct {
ID string
Class string
Attributes templ.Attributes
For string // Corresponds to the ID of the Slider Input
}
templ Slider(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("w-full", p.Class) }
data-tui-slider-wrapper
{ p.Attributes... }
>
{ children... }
</div>
}
templ Input(props ...InputProps) {
{{ var p InputProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<input
type="range"
id={ p.ID }
data-tui-slider-input
if p.Name != "" {
name={ p.Name }
}
if p.Value != 0 {
value={ fmt.Sprintf("%d", p.Value) }
}
if p.Min != 0 {
min={ fmt.Sprintf("%d", p.Min) }
}
if p.Max != 0 {
max={ fmt.Sprintf("%d", p.Max) }
}
if p.Step != 0 {
step={ fmt.Sprintf("%d", p.Step) }
}
class={
utils.TwMerge(
"w-full h-2 rounded-full bg-secondary appearance-none cursor-pointer",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-4 [&::-webkit-slider-thumb]:h-4",
"[&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-primary",
"[&::-webkit-slider-thumb]:hover:bg-primary/90",
"[&::-moz-range-thumb]:w-4 [&::-moz-range-thumb]:h-4 [&::-moz-range-thumb]:border-0",
"[&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-primary",
"[&::-moz-range-thumb]:hover:bg-primary/90",
"disabled:opacity-50 disabled:cursor-not-allowed",
p.Class,
),
}
disabled?={ p.Disabled }
{ p.Attributes... }
/>
}
templ Value(props ...ValueProps) {
{{ var p ValueProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.For == "" {
<span class="text-xs text-destructive">Error: SliderValue missing 'For' attribute.</span>
}
<span
if p.ID != "" {
id={ p.ID }
}
data-tui-slider-value
data-tui-slider-value-for={ p.For }
class={ utils.TwMerge("text-sm text-muted-foreground", p.Class) }
{ p.Attributes... }
>
<!-- Initial value will be set by JS -->
</span>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/slider.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,86 @@
// templui component switch - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/switch
package switchcomp
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Value string
Disabled bool
Checked bool
Form string
}
templ Switch(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<label
for={ p.ID }
class={ utils.TwMerge(
"inline-flex cursor-pointer items-center gap-2",
utils.If(p.Disabled, "cursor-not-allowed"),
) }
>
<!-- Actual checkbox switch -->
<input
id={ p.ID }
if p.Name != "" {
name={ p.Name }
}
type="checkbox"
if p.Value != "" {
value={ p.Value }
} else {
value="on"
}
if p.Form != "" {
form={ p.Form }
}
checked?={ p.Checked }
disabled?={ p.Disabled }
class="peer hidden"
role="switch"
{ p.Attributes... }
/>
<!-- Visual switch UI -->
<div
class={
utils.TwMerge(
// Container
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer items-center",
"rounded-full border-2 border-transparent",
"transition-colors",
// Background colors
"bg-input",
"peer-checked:bg-primary",
// Focus styles
"peer-focus-visible:outline-none peer-focus-visible:ring-2",
"peer-focus-visible:ring-ring peer-focus-visible:ring-offset-2",
"peer-focus-visible:ring-offset-background",
// Disabled state
"peer-disabled:cursor-not-allowed peer-disabled:opacity-50",
// Thumb
"after:pointer-events-none after:block",
"after:h-4 after:w-4",
"after:rounded-full after:bg-background",
"after:shadow-lg after:ring-0",
"after:transition-transform",
"after:content-['']",
// Thumb position
"peer-checked:after:translate-x-4",
p.Class,
),
}
aria-hidden="true"
></div>
</label>
}

View file

@ -0,0 +1,205 @@
// templui component table - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/table
package table
import "git.juancwu.dev/juancwu/budgething/internal/utils"
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type HeaderProps struct {
ID string
Class string
Attributes templ.Attributes
}
type BodyProps struct {
ID string
Class string
Attributes templ.Attributes
}
type FooterProps struct {
ID string
Class string
Attributes templ.Attributes
}
type RowProps struct {
ID string
Class string
Attributes templ.Attributes
Selected bool
}
type HeadProps struct {
ID string
Class string
Attributes templ.Attributes
}
type CellProps struct {
ID string
Class string
Attributes templ.Attributes
}
type CaptionProps struct {
ID string
Class string
Attributes templ.Attributes
}
templ Table(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div class="relative w-full overflow-auto">
<table
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("w-full caption-bottom text-sm", p.Class) }
{ p.Attributes... }
>
{ children... }
</table>
</div>
}
templ Header(props ...HeaderProps) {
{{ var p HeaderProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<thead
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("[&_tr]:border-b", p.Class) }
{ p.Attributes... }
>
{ children... }
</thead>
}
templ Body(props ...BodyProps) {
{{ var p BodyProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<tbody
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("[&_tr:last-child]:border-0", p.Class) }
{ p.Attributes... }
>
{ children... }
</tbody>
}
templ Footer(props ...FooterProps) {
{{ var p FooterProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<tfoot
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("border-t bg-muted/50 font-medium [&>tr]:last:border-b-0", p.Class) }
{ p.Attributes... }
>
{ children... }
</tfoot>
}
templ Row(props ...RowProps) {
{{ var p RowProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<tr
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"border-b transition-colors hover:bg-muted/50",
utils.If(p.Selected, "data-[tui-table-state-selected]:bg-muted"),
p.Class,
),
}
if p.Selected {
data-tui-table-state-selected
}
{ p.Attributes... }
>
{ children... }
</tr>
}
templ Head(props ...HeadProps) {
{{ var p HeadProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<th
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"h-10 px-2 text-left align-middle font-medium text-muted-foreground",
"[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</th>
}
templ Cell(props ...CellProps) {
{{ var p CellProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<td
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"p-2 align-middle",
"[&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]",
p.Class,
),
}
{ p.Attributes... }
>
{ children... }
</td>
}
templ Caption(props ...CaptionProps) {
{{ var p CaptionProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
<caption
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("mt-4 text-sm text-muted-foreground", p.Class) }
{ p.Attributes... }
>
{ children... }
</caption>
}

View file

@ -0,0 +1,163 @@
// templui component tabs - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/tabs
package tabs
import (
"context"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type ListProps struct {
ID string
Class string
Attributes templ.Attributes
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
IsActive bool
TabsID string
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
Value string
IsActive bool
TabsID string
}
templ Tabs(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ tabsID := p.ID }}
if tabsID == "" {
{{ tabsID = utils.RandomID() }}
}
<div
if p.ID != "" {
id={ p.ID }
}
class={ utils.TwMerge("flex flex-col gap-2", p.Class) }
data-tui-tabs
data-tui-tabs-id={ tabsID }
{ p.Attributes... }
>
{{ ctx = context.WithValue(ctx, "tabsId", tabsID) }}
{ children... }
</div>
}
templ List(props ...ListProps) {
{{ var p ListProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ tabsID := IDFromContext(ctx) }}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"bg-muted text-muted-foreground inline-flex h-9 w-fit items-center justify-center rounded-lg p-[3px]",
p.Class,
),
}
data-tui-tabs-list
data-tui-tabs-id={ tabsID }
{ p.Attributes... }
>
{ children... }
</div>
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ tabsID := p.TabsID }}
if tabsID == "" {
{{ tabsID = IDFromContext(ctx) }}
}
if p.Value == "" {
<span class="text-xs text-destructive">Error: Tab Trigger missing required 'Value' attribute.</span>
}
<button
if p.ID != "" {
id={ p.ID }
}
type="button"
class={
utils.TwMerge(
"data-[tui-tabs-state=active]:bg-background dark:data-[tui-tabs-state=active]:text-foreground focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:outline-ring dark:data-[tui-tabs-state=active]:border-input dark:data-[tui-tabs-state=active]:bg-input/30 text-foreground dark:text-muted-foreground inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-1 text-sm font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:ring-[3px] focus-visible:outline-1 disabled:pointer-events-none disabled:opacity-50 data-[tui-tabs-state=active]:shadow-sm [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
p.Class,
),
}
data-tui-tabs-trigger
data-tui-tabs-id={ tabsID }
data-tui-tabs-value={ p.Value }
data-tui-tabs-state={ utils.IfElse(p.IsActive, "active", "inactive") }
{ p.Attributes... }
>
{ children... }
</button>
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
{{ tabsID := p.TabsID }}
if tabsID == "" {
{{ tabsID = IDFromContext(ctx) }}
}
if p.Value == "" {
<span class="text-xs text-destructive">Error: Tab Content missing required 'Value' attribute.</span>
return templ.NopComponent
}
<div
if p.ID != "" {
id={ p.ID }
}
class={
utils.TwMerge(
"flex-1 outline-none",
utils.If(!p.IsActive, "hidden"),
p.Class,
),
}
data-tui-tabs-content
data-tui-tabs-id={ tabsID }
data-tui-tabs-value={ p.Value }
data-tui-tabs-state={ utils.IfElse(p.IsActive, "active", "inactive") }
{ p.Attributes... }
>
{ children... }
</div>
}
func IDFromContext(ctx context.Context) string {
if tabsID, ok := ctx.Value("tabsId").(string); ok {
return tabsID
}
return ""
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/tabs.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,94 @@
// templui component tagsinput - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/tags-input
package tagsinput
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/badge"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/input"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Props struct {
ID string
Name string
Value []string
Form string
Placeholder string
Class string
HasError bool
Attributes templ.Attributes
Disabled bool
Readonly bool
}
templ TagsInput(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
<div
id={ p.ID + "-container" }
class={
utils.TwMerge(
// Base styles
"flex items-center flex-wrap gap-2 p-2 rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30",
// Focus styles
"focus-within:border-ring focus-within:ring-ring/50 focus-within:ring-[3px]",
// Disabled styles
utils.If(p.Disabled, "opacity-50 cursor-not-allowed"),
// Width
"w-full",
// Error/Invalid styles
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
p.Class,
),
}
data-tui-tagsinput
data-tui-tagsinput-name={ p.Name }
data-tui-tagsinput-form={ p.Form }
{ p.Attributes... }
>
<div class="flex items-center flex-wrap gap-2" data-tui-tagsinput-container>
for _, tag := range p.Value {
@badge.Badge(badge.Props{
Attributes: templ.Attributes{"data-tui-tagsinput-chip": ""},
}) {
<span>{ tag }</span>
<button
type="button"
class="ml-1 text-current hover:text-destructive disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer"
disabled?={ p.Disabled }
data-tui-tagsinput-remove
>
<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>
@input.Input(input.Props{
ID: p.ID,
Class: "border-0 shadow-none focus-visible:ring-0 h-auto py-0 px-0 bg-transparent rounded-none min-h-0 disabled:opacity-100 dark:bg-transparent",
Type: input.TypeText,
Placeholder: p.Placeholder,
Disabled: p.Disabled,
Readonly: p.Readonly,
Attributes: utils.MergeAttributes(
templ.Attributes{"data-tui-tagsinput-text-input": ""},
p.Attributes,
),
})
<div data-tui-tagsinput-hidden-inputs>
for _, tag := range p.Value {
<input type="hidden" name={ p.Name } value={ tag }/>
}
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/tagsinput.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,85 @@
// templui component textarea - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/textarea
package textarea
import (
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Value string
Form string
Placeholder string
Rows int
AutoResize bool
Disabled bool
Readonly bool
HasError bool
}
templ Textarea(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
<textarea
id={ p.ID }
data-tui-textarea
if p.Name != "" {
name={ p.Name }
}
if p.Form != "" {
form={ p.Form }
}
if p.Placeholder != "" {
placeholder={ p.Placeholder }
}
if p.Rows > 0 {
rows={ strconv.Itoa(p.Rows) }
}
disabled?={ p.Disabled }
readonly?={ p.Readonly }
if p.HasError {
aria-invalid="true"
}
if p.AutoResize {
data-tui-textarea-auto-resize="true"
}
class={
utils.TwMerge(
// Base styles
"flex w-full min-w-0 rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none md:text-sm",
"min-h-[80px]", // Default min-height
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Placeholder
"placeholder:text-muted-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Disabled styles
"disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
// Add overflow-hidden only if auto-resizing to prevent scrollbar flicker
utils.If(p.AutoResize, "overflow-hidden resize-none"),
p.Class,
),
}
{ p.Attributes... }
>{ p.Value }</textarea>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/textarea.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,250 @@
// templui component timepicker - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/time-picker
package timepicker
import (
"fmt"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/card"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
"time"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Name string
Form string
Value time.Time
MinTime time.Time
MaxTime time.Time
Step int
Use12Hours bool
AMLabel string
PMLabel string
Placeholder string
Disabled bool
HasError bool
}
templ TimePicker(props ...Props) {
{{
var p Props
if len(props) > 0 {
p = props[0]
}
if p.ID == "" {
p.ID = utils.RandomID()
}
if p.Name == "" {
p.Name = p.ID
}
if p.Placeholder == "" {
p.Placeholder = "Select time"
}
if p.AMLabel == "" {
p.AMLabel = "AM"
}
if p.PMLabel == "" {
p.PMLabel = "PM"
}
if p.Step <= 0 {
p.Step = 1
}
var contentID = p.ID + "-content"
var valueString string
if p.Value != (time.Time{}) {
valueString = p.Value.Format("15:04")
}
var minTimeString string
if p.MinTime != (time.Time{}) {
minTimeString = p.MinTime.Format("15:04")
}
var maxTimeString string
if p.MaxTime != (time.Time{}) {
maxTimeString = p.MaxTime.Format("15:04")
}
}}
<div class="relative inline-block w-full">
<input
type="hidden"
name={ p.Name }
value={ valueString }
if p.Form != "" {
form={ p.Form }
}
id={ p.ID + "-hidden" }
data-tui-timepicker-hidden-input="true"
/>
@popover.Trigger(popover.TriggerProps{For: contentID}) {
@button.Button(button.Props{
ID: p.ID,
Variant: button.VariantOutline,
Class: utils.TwMerge(
// Base styles matching input
"w-full h-9 px-3 py-1 text-base md:text-sm",
"flex items-center justify-between",
"rounded-md border border-input bg-transparent shadow-xs transition-[color,box-shadow] outline-none",
// Dark mode background
"dark:bg-input/30",
// Selection styles
"selection:bg-primary selection:text-primary-foreground",
// Focus styles
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
// Error/Invalid styles
"aria-invalid:ring-destructive/20 aria-invalid:border-destructive dark:aria-invalid:ring-destructive/40",
utils.If(p.HasError, "border-destructive ring-destructive/20 dark:ring-destructive/40"),
p.Class,
),
Disabled: p.Disabled,
Attributes: utils.MergeAttributes(p.Attributes, templ.Attributes{
"data-tui-timepicker": "true",
"data-tui-timepicker-use12hours": fmt.Sprintf("%t", p.Use12Hours),
"data-tui-timepicker-am-label": p.AMLabel,
"data-tui-timepicker-pm-label": p.PMLabel,
"data-tui-timepicker-placeholder": p.Placeholder,
"data-tui-timepicker-step": fmt.Sprintf("%d", p.Step),
"data-tui-timepicker-min-time": minTimeString,
"data-tui-timepicker-max-time": maxTimeString,
"aria-invalid": utils.If(p.HasError, "true"),
}),
}) {
<span data-tui-timepicker-display class="text-left grow text-muted-foreground">
{ p.Placeholder }
</span>
<span class="text-muted-foreground flex items-center ml-2">
@icon.Clock(icon.Props{Size: 16})
</span>
}
}
@popover.Content(popover.ContentProps{
ID: contentID,
Placement: popover.PlacementBottomStart,
Class: "p-0 w-80",
}) {
@card.Card(card.Props{
Class: "border-0 shadow-none",
}) {
@card.Content(card.ContentProps{
Class: "p-4",
}) {
<div
data-tui-timepicker-popup="true"
data-tui-timepicker-input-name={ p.Name }
data-tui-timepicker-parent-id={ p.ID }
if valueString != "" {
data-tui-timepicker-value={ valueString }
}
>
// Time selection grid
<div class="grid grid-cols-2 gap-3 mb-4">
// Hour selection
<div class="space-y-2">
<label class="text-sm font-medium">Hour</label>
<div class="max-h-32 overflow-y-auto border rounded-md bg-background">
<div data-tui-timepicker-hour-list="true" class="p-1 space-y-0.5">
if p.Use12Hours {
// 12-hour format: 12, 01-11
<button
type="button"
data-tui-timepicker-hour="0"
data-tui-timepicker-selected="false"
class="w-full px-2 py-1 text-sm rounded transition-colors text-left hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-selected=true]:bg-primary data-[tui-timepicker-selected=true]:text-primary-foreground data-[tui-timepicker-selected=true]:hover:bg-primary/90"
>
12
</button>
for hour := 1; hour <= 11; hour++ {
<button
type="button"
data-tui-timepicker-hour={ strconv.Itoa(hour) }
data-tui-timepicker-selected="false"
class="w-full px-2 py-1 text-sm rounded transition-colors text-left hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-selected=true]:bg-primary data-[tui-timepicker-selected=true]:text-primary-foreground data-[tui-timepicker-selected=true]:hover:bg-primary/90"
>
{ fmt.Sprintf("%02d", hour) }
</button>
}
} else {
// 24-hour format: 00-23
for hour := 0; hour < 24; hour++ {
<button
type="button"
data-tui-timepicker-hour={ strconv.Itoa(hour) }
data-tui-timepicker-selected="false"
class="w-full px-2 py-1 text-sm rounded transition-colors text-left hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-selected=true]:bg-primary data-[tui-timepicker-selected=true]:text-primary-foreground data-[tui-timepicker-selected=true]:hover:bg-primary/90"
>
{ fmt.Sprintf("%02d", hour) }
</button>
}
}
</div>
</div>
</div>
// Minute selection
<div class="space-y-2">
<label class="text-sm font-medium">Minute</label>
<div class="max-h-32 overflow-y-auto border rounded-md bg-background">
<div data-tui-timepicker-minute-list="true" class="p-1 space-y-0.5">
for minute := 0; minute < 60; minute += p.Step {
<button
type="button"
data-tui-timepicker-minute={ strconv.Itoa(minute) }
data-tui-timepicker-selected="false"
class="w-full px-2 py-1 text-sm rounded transition-colors text-left hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-selected=true]:bg-primary data-[tui-timepicker-selected=true]:text-primary-foreground data-[tui-timepicker-selected=true]:hover:bg-primary/90"
>
{ fmt.Sprintf("%02d", minute) }
</button>
}
</div>
</div>
</div>
</div>
// AM/PM selector and action buttons
<div class="flex justify-between items-center">
if p.Use12Hours {
<div class="flex gap-1">
<button
type="button"
data-tui-timepicker-period="AM"
data-tui-timepicker-active="false"
class="px-3 py-1 text-sm rounded-md border transition-colors hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-active=true]:bg-primary data-[tui-timepicker-active=true]:text-primary-foreground data-[tui-timepicker-active=true]:hover:bg-primary/90"
>
{ p.AMLabel }
</button>
<button
type="button"
data-tui-timepicker-period="PM"
data-tui-timepicker-active="false"
class="px-3 py-1 text-sm rounded-md border transition-colors hover:bg-accent hover:text-accent-foreground data-[tui-timepicker-active=true]:bg-primary data-[tui-timepicker-active=true]:text-primary-foreground data-[tui-timepicker-active=true]:hover:bg-primary/90"
>
{ p.PMLabel }
</button>
</div>
} else {
<div></div>
}
@button.Button(button.Props{
Type: "button",
Variant: button.VariantSecondary,
Size: button.SizeSm,
Attributes: templ.Attributes{
"data-tui-timepicker-done": "true",
},
}) {
Done
}
</div>
</div>
}
}
}
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/timepicker.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,153 @@
// templui component toast - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/toast
package toast
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/button"
"git.juancwu.dev/juancwu/budgething/internal/ui/components/icon"
"git.juancwu.dev/juancwu/budgething/internal/utils"
"strconv"
)
type Variant string
type Position string
const (
VariantDefault Variant = "default"
VariantSuccess Variant = "success"
VariantError Variant = "error"
VariantWarning Variant = "warning"
VariantInfo Variant = "info"
)
const (
PositionTopRight Position = "top-right"
PositionTopLeft Position = "top-left"
PositionTopCenter Position = "top-center"
PositionBottomRight Position = "bottom-right"
PositionBottomLeft Position = "bottom-left"
PositionBottomCenter Position = "bottom-center"
)
type Props struct {
ID string
Class string
Attributes templ.Attributes
Title string
Description string
Variant Variant
Position Position
Duration int
Dismissible bool
ShowIndicator bool
Icon bool
}
templ Toast(props ...Props) {
{{ var p Props }}
if len(props) > 0 {
{{ p = props[0] }}
}
if p.ID == "" {
{{ p.ID = utils.RandomID() }}
}
// Set defaults
if p.Variant == "" {
{{ p.Variant = VariantDefault }}
}
if p.Position == "" {
{{ p.Position = PositionBottomRight }}
}
if p.Duration == 0 {
{{ p.Duration = 3000 }}
}
<div
id={ p.ID }
data-tui-toast
data-tui-toast-duration={ strconv.Itoa(p.Duration) }
data-position={ string(p.Position) }
data-variant={ string(p.Variant) }
class={ utils.TwMerge(
// Base styles
"z-50 fixed pointer-events-auto p-4 w-full md:max-w-[420px]",
// Animation
"animate-in fade-in slide-in-from-bottom-4 duration-300",
// Position-based styles using data attributes
"data-[position=top-right]:top-0 data-[position=top-right]:right-0",
"data-[position=top-left]:top-0 data-[position=top-left]:left-0",
"data-[position=top-center]:top-0 data-[position=top-center]:left-1/2 data-[position=top-center]:-translate-x-1/2",
"data-[position=bottom-right]:bottom-0 data-[position=bottom-right]:right-0",
"data-[position=bottom-left]:bottom-0 data-[position=bottom-left]:left-0",
"data-[position=bottom-center]:bottom-0 data-[position=bottom-center]:left-1/2 data-[position=bottom-center]:-translate-x-1/2",
// Slide direction based on position
"data-[position*=top]:slide-in-from-top-4",
"data-[position*=bottom]:slide-in-from-bottom-4",
p.Class,
) }
{ p.Attributes... }
>
<div class="w-full bg-popover text-popover-foreground rounded-lg shadow-xs border pt-5 pb-4 px-4 flex items-center justify-center relative overflow-hidden group">
// Progress indicator
if p.ShowIndicator && p.Duration > 0 {
<div class="absolute top-0 left-0 right-0 h-1 overflow-hidden">
<div
class={ utils.TwMerge(
"toast-progress h-full origin-left transition-transform ease-linear",
// Variant colors
"data-[variant=default]:bg-gray-500",
"data-[variant=success]:bg-green-500",
"data-[variant=error]:bg-red-500",
"data-[variant=warning]:bg-yellow-500",
"data-[variant=info]:bg-blue-500",
) }
data-variant={ string(p.Variant) }
data-duration={ strconv.Itoa(p.Duration) }
></div>
</div>
}
// Icon
if p.Icon {
switch p.Variant {
case VariantSuccess:
@icon.CircleCheck(icon.Props{Size: 22, Class: "text-green-500 mr-3 flex-shrink-0"})
case VariantError:
@icon.CircleX(icon.Props{Size: 22, Class: "text-red-500 mr-3 flex-shrink-0"})
case VariantWarning:
@icon.TriangleAlert(icon.Props{Size: 22, Class: "text-yellow-500 mr-3 flex-shrink-0"})
case VariantInfo:
@icon.Info(icon.Props{Size: 22, Class: "text-blue-500 mr-3 flex-shrink-0"})
}
}
// Content
<span class="flex-1 min-w-0">
if p.Title != "" {
<p class="text-sm font-semibold truncate">{ p.Title }</p>
}
if p.Description != "" {
<p class="text-sm opacity-90 mt-1">{ p.Description }</p>
}
</span>
// Dismiss button
if p.Dismissible {
@button.Button(button.Props{
Size: button.SizeIcon,
Variant: button.VariantGhost,
Attributes: templ.Attributes{
"aria-label": "Close",
"data-tui-toast-dismiss": "",
"type": "button",
},
}) {
@icon.X(icon.Props{
Size: 18,
Class: "opacity-75 hover:opacity-100",
})
}
}
</div>
</div>
}
templ Script() {
<script defer nonce={ templ.GetNonce(ctx) } src={ "/assets/js/toast.min.js?v=" + utils.ScriptVersion }></script>
}

View file

@ -0,0 +1,94 @@
// templui component tooltip - version: v0.101.0 installed by templui v0.101.0
// 📚 Documentation: https://templui.io/docs/components/tooltip
package tooltip
import (
"git.juancwu.dev/juancwu/budgething/internal/ui/components/popover"
"git.juancwu.dev/juancwu/budgething/internal/utils"
)
type Position string
const (
PositionTop Position = "top"
PositionRight Position = "right"
PositionBottom Position = "bottom"
PositionLeft Position = "left"
)
// Map tooltip positions to popover positions
func mapTooltipPositionToPopover(position Position) popover.Placement {
switch position {
case PositionTop:
return popover.PlacementTop
case PositionRight:
return popover.PlacementRight
case PositionBottom:
return popover.PlacementBottom
case PositionLeft:
return popover.PlacementLeft
default:
return popover.PlacementTop
}
}
type Props struct {
ID string
Class string
Attributes templ.Attributes
}
type TriggerProps struct {
ID string
Class string
Attributes templ.Attributes
For string
}
type ContentProps struct {
ID string
Class string
Attributes templ.Attributes
ShowArrow bool
Position Position
HoverDelay int
HoverOutDelay int
}
templ Tooltip(props ...Props) {
{ children... }
}
templ Trigger(props ...TriggerProps) {
{{ var p TriggerProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@popover.Trigger(popover.TriggerProps{
ID: p.ID,
Class: p.Class,
Attributes: p.Attributes,
TriggerType: popover.TriggerTypeHover,
For: p.For,
}) {
{ children... }
}
}
templ Content(props ...ContentProps) {
{{ var p ContentProps }}
if len(props) > 0 {
{{ p = props[0] }}
}
@popover.Content(popover.ContentProps{
ID: p.ID,
Class: utils.TwMerge("px-4 py-1 bg-foreground text-background [&_[data-tui-popover-arrow]]:!bg-foreground [&_[data-tui-popover-arrow]]:!border-0", p.Class),
Attributes: p.Attributes,
Placement: mapTooltipPositionToPopover(p.Position),
ShowArrow: p.ShowArrow,
HoverDelay: p.HoverDelay,
HoverOutDelay: p.HoverOutDelay,
}) {
{ children... }
}
}