Skip to content

Commit 554716f

Browse files
committed
Consolidate UI into ui and format packages
1 parent c799c18 commit 554716f

32 files changed

+1122
-3365
lines changed

docs/features.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@
1616
| `htmx` | HTMX primitives (triggers, actions, targets, swaps), response headers, template helpers |
1717
| `middleware` | Request ID, role-based access, rate limiting, locale detection, static cache |
1818
| `render` | Template FuncMap utilities, i18n integration |
19-
| `render/ui` | UI components (Badge, Chip, Price, Stat, Button, Link, Form, Input, Alert, Toast) |
20-
| `ui` | UI kit with dependency injection, HTMX-first components, emoji support, CSRF integration |
19+
| `ui` | UI kit with HTMX-first components (Chip, Label, Button, Alert, Flash, Toast, Link, Form, Table, Nav) |
20+
| `format` | Formatting utilities (Price, Number) for templates |
2121
| `modal` | Modal dialog configuration |
2222
| `pagination` | Generic pagination (`Result[T]`) |
2323
| `i18n` | Internationalization with YAML translation files |

format/number.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package format
2+
3+
import (
4+
"golang.org/x/text/language"
5+
"golang.org/x/text/message"
6+
"golang.org/x/text/number"
7+
)
8+
9+
// defaultPrinter is used for number formatting.
10+
var defaultPrinter = message.NewPrinter(language.English)
11+
12+
// Number formats a number with thousand separators.
13+
// Examples:
14+
//
15+
// Number(1234) // "1,234"
16+
// Number(1234567) // "1,234,567"
17+
// Number(1234.56) // "1,234.56"
18+
func Number(n any) string {
19+
return defaultPrinter.Sprintf("%v", number.Decimal(n))
20+
}
21+
22+
// Integer formats a number as an integer with thousand separators.
23+
// Examples:
24+
//
25+
// Integer(1234.56) // "1,235"
26+
// Integer(1234567) // "1,234,567"
27+
func Integer(n any) string {
28+
return defaultPrinter.Sprintf("%v", number.Decimal(n, number.MaxFractionDigits(0)))
29+
}

format/number_test.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
package format
2+
3+
import "testing"
4+
5+
func TestNumber(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
n any
9+
want string
10+
}{
11+
{"integer", 1234, "1,234"},
12+
{"large integer", 1234567, "1,234,567"},
13+
{"float", 1234.56, "1,234.56"},
14+
{"zero", 0, "0"},
15+
{"negative", -1234, "-1,234"},
16+
}
17+
18+
for _, tt := range tests {
19+
t.Run(tt.name, func(t *testing.T) {
20+
got := Number(tt.n)
21+
if got != tt.want {
22+
t.Errorf("Number(%v) = %q, want %q", tt.n, got, tt.want)
23+
}
24+
})
25+
}
26+
}
27+
28+
func TestInteger(t *testing.T) {
29+
tests := []struct {
30+
name string
31+
n any
32+
want string
33+
}{
34+
{"integer", 1234, "1,234"},
35+
{"float rounds", 1234.56, "1,235"},
36+
{"large", 1234567, "1,234,567"},
37+
{"zero", 0, "0"},
38+
{"negative", -1234.5, "-1,234"},
39+
}
40+
41+
for _, tt := range tests {
42+
t.Run(tt.name, func(t *testing.T) {
43+
got := Integer(tt.n)
44+
if got != tt.want {
45+
t.Errorf("Integer(%v) = %q, want %q", tt.n, got, tt.want)
46+
}
47+
})
48+
}
49+
}

format/price.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package format
2+
3+
import "fmt"
4+
5+
// CurrencyPosition indicates where the symbol appears relative to the amount.
6+
type CurrencyPosition int
7+
8+
const (
9+
SymbolBefore CurrencyPosition = iota
10+
SymbolAfter
11+
)
12+
13+
// CurrencyFormat holds formatting preferences for a currency.
14+
type CurrencyFormat struct {
15+
Symbol string
16+
Position CurrencyPosition
17+
}
18+
19+
// currencies maps currency codes to their format preferences.
20+
var currencies = map[string]CurrencyFormat{
21+
"USD": {Symbol: "$", Position: SymbolBefore},
22+
"EUR": {Symbol: "€", Position: SymbolAfter},
23+
"GBP": {Symbol: "£", Position: SymbolBefore},
24+
"PLN": {Symbol: "zł", Position: SymbolAfter},
25+
"ARS": {Symbol: "$", Position: SymbolBefore},
26+
"BRL": {Symbol: "R$", Position: SymbolBefore},
27+
"MXN": {Symbol: "$", Position: SymbolBefore},
28+
}
29+
30+
// RegisterCurrency adds or updates a currency format.
31+
func RegisterCurrency(code, symbol string, position CurrencyPosition) {
32+
currencies[code] = CurrencyFormat{Symbol: symbol, Position: position}
33+
}
34+
35+
// Price formats a price amount with the given currency.
36+
// Examples:
37+
//
38+
// Price(150000, "USD") // "$150,000"
39+
// Price(150000, "EUR") // "150,000 €"
40+
// Price(99.99, "GBP") // "£100"
41+
func Price(amount float64, currency string) string {
42+
format, ok := currencies[currency]
43+
if !ok {
44+
format = CurrencyFormat{Symbol: currency, Position: SymbolBefore}
45+
}
46+
47+
formatted := Integer(amount)
48+
49+
if format.Position == SymbolAfter {
50+
return fmt.Sprintf("%s %s", formatted, format.Symbol)
51+
}
52+
return fmt.Sprintf("%s%s", format.Symbol, formatted)
53+
}
54+
55+
// PriceWithDecimals formats a price with decimal places.
56+
// Examples:
57+
//
58+
// PriceWithDecimals(99.99, "USD") // "$99.99"
59+
// PriceWithDecimals(99.99, "EUR") // "99.99 €"
60+
func PriceWithDecimals(amount float64, currency string) string {
61+
format, ok := currencies[currency]
62+
if !ok {
63+
format = CurrencyFormat{Symbol: currency, Position: SymbolBefore}
64+
}
65+
66+
formatted := Number(amount)
67+
68+
if format.Position == SymbolAfter {
69+
return fmt.Sprintf("%s %s", formatted, format.Symbol)
70+
}
71+
return fmt.Sprintf("%s%s", format.Symbol, formatted)
72+
}
73+
74+
// PriceRange formats a price range.
75+
// Examples:
76+
//
77+
// PriceRange(100, 500, "USD") // "$100 - $500"
78+
// PriceRange(100, 500, "EUR") // "100 € - 500 €"
79+
func PriceRange(min, max float64, currency string) string {
80+
return fmt.Sprintf("%s - %s", Price(min, currency), Price(max, currency))
81+
}

format/price_test.go

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
package format
2+
3+
import "testing"
4+
5+
func TestPrice(t *testing.T) {
6+
tests := []struct {
7+
name string
8+
amount float64
9+
currency string
10+
want string
11+
}{
12+
{"USD", 150000, "USD", "$150,000"},
13+
{"EUR symbol after", 150000, "EUR", "150,000 €"},
14+
{"GBP", 99.99, "GBP", "£100"},
15+
{"PLN symbol after", 1500, "PLN", "1,500 zł"},
16+
{"ARS", 50000, "ARS", "$50,000"},
17+
{"BRL", 1000, "BRL", "R$1,000"},
18+
{"MXN", 25000, "MXN", "$25,000"},
19+
{"unknown currency uses code", 100, "XYZ", "XYZ100"},
20+
{"zero", 0, "USD", "$0"},
21+
{"negative", -500, "USD", "$-500"},
22+
}
23+
24+
for _, tt := range tests {
25+
t.Run(tt.name, func(t *testing.T) {
26+
got := Price(tt.amount, tt.currency)
27+
if got != tt.want {
28+
t.Errorf("Price(%v, %q) = %q, want %q", tt.amount, tt.currency, got, tt.want)
29+
}
30+
})
31+
}
32+
}
33+
34+
func TestPriceWithDecimals(t *testing.T) {
35+
tests := []struct {
36+
name string
37+
amount float64
38+
currency string
39+
want string
40+
}{
41+
{"USD", 99.99, "USD", "$99.99"},
42+
{"EUR symbol after", 99.99, "EUR", "99.99 €"},
43+
{"whole number", 100.00, "USD", "$100"},
44+
{"unknown currency", 50.5, "XYZ", "XYZ50.5"},
45+
}
46+
47+
for _, tt := range tests {
48+
t.Run(tt.name, func(t *testing.T) {
49+
got := PriceWithDecimals(tt.amount, tt.currency)
50+
if got != tt.want {
51+
t.Errorf("PriceWithDecimals(%v, %q) = %q, want %q", tt.amount, tt.currency, got, tt.want)
52+
}
53+
})
54+
}
55+
}
56+
57+
func TestPriceRange(t *testing.T) {
58+
tests := []struct {
59+
name string
60+
min float64
61+
max float64
62+
currency string
63+
want string
64+
}{
65+
{"USD range", 100, 500, "USD", "$100 - $500"},
66+
{"EUR range", 100, 500, "EUR", "100 € - 500 €"},
67+
{"same value", 100, 100, "USD", "$100 - $100"},
68+
}
69+
70+
for _, tt := range tests {
71+
t.Run(tt.name, func(t *testing.T) {
72+
got := PriceRange(tt.min, tt.max, tt.currency)
73+
if got != tt.want {
74+
t.Errorf("PriceRange(%v, %v, %q) = %q, want %q", tt.min, tt.max, tt.currency, got, tt.want)
75+
}
76+
})
77+
}
78+
}
79+
80+
func TestRegisterCurrency(t *testing.T) {
81+
RegisterCurrency("CLP", "$", SymbolBefore)
82+
got := Price(1000, "CLP")
83+
want := "$1,000"
84+
if got != want {
85+
t.Errorf("Price after RegisterCurrency = %q, want %q", got, want)
86+
}
87+
88+
RegisterCurrency("JPY", "¥", SymbolBefore)
89+
got = Price(10000, "JPY")
90+
want = "¥10,000"
91+
if got != want {
92+
t.Errorf("Price for JPY = %q, want %q", got, want)
93+
}
94+
}

format/readme.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# format
2+
3+
Formatting utilities for prices and numbers. Pure functions without HTML output.
4+
5+
## Usage
6+
7+
```go
8+
import "github.com/hatmaxkit/hatmax/format"
9+
10+
// Numbers
11+
format.Number(1234567) // "1,234,567"
12+
format.Integer(1234.56) // "1,235"
13+
14+
// Prices
15+
format.Price(150000, "USD") // "$150,000"
16+
format.Price(150000, "EUR") // "150,000 €"
17+
format.PriceWithDecimals(99.99, "USD") // "$99.99"
18+
format.PriceRange(100, 500, "USD") // "$100 - $500"
19+
20+
// Register custom currencies
21+
format.RegisterCurrency("CLP", "$", format.SymbolBefore)
22+
```
23+
24+
## Template Usage
25+
26+
Available via `ui.FuncMap()`:
27+
28+
```html
29+
<span class="price">{{ formatPrice .Amount .Currency }}</span>
30+
<span class="count">{{ formatNumber .Count }}</span>
31+
```

render/funcmap.go

Lines changed: 2 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@ import (
66

77
"github.com/hatmaxkit/hatmax/htmx"
88
"github.com/hatmaxkit/hatmax/i18n"
9-
"github.com/hatmaxkit/hatmax/render/ui"
109
)
1110

12-
// FuncMap returns a template.FuncMap with all render functions.
11+
// FuncMap returns a template.FuncMap with base render functions.
12+
// For UI components, use ui.FuncMap() which extends this.
1313
func FuncMap() template.FuncMap {
1414
return template.FuncMap{
1515
// String
@@ -32,66 +32,6 @@ func FuncMap() template.FuncMap {
3232
}
3333
return result
3434
},
35-
36-
// Chips
37-
"chip": ui.Chip,
38-
"chipMuted": ui.ChipMuted,
39-
"chipWithClass": ui.ChipWithClass,
40-
41-
// Pills
42-
"pill": ui.Pill,
43-
"pillMuted": ui.PillMuted,
44-
"pillWithClass": ui.PillWithClass,
45-
46-
// Badges
47-
"badge": ui.Badge,
48-
"badgeWithVariant": ui.BadgeWithVariant,
49-
"statusBadge": ui.StatusBadge,
50-
"statusBadgeWithIcon": ui.StatusBadgeWithIcon,
51-
52-
// Prices
53-
"formatPrice": ui.FormatPrice,
54-
"priceTag": ui.PriceTag,
55-
"priceTagNegotiable": ui.PriceTagNegotiable,
56-
"priceRange": ui.PriceRange,
57-
58-
// Stats
59-
"formatNumber": ui.FormatNumber,
60-
"stat": ui.Stat,
61-
"statWithIcon": ui.StatWithIcon,
62-
"statCompact": ui.StatCompact,
63-
64-
// Buttons
65-
"btn": ui.Btn,
66-
"btnSubmit": ui.BtnSubmit,
67-
"btnDanger": ui.BtnDanger,
68-
69-
// Links
70-
"link": ui.A,
71-
"linkBlank": ui.ABlank,
72-
"linkBoosted": ui.ABoosted,
73-
"navLink": ui.Nav,
74-
75-
// Forms
76-
"form": ui.NewForm,
77-
"input": ui.NewInput,
78-
"text": ui.Text,
79-
"email": ui.Email,
80-
"password": ui.Password,
81-
"number": ui.Number,
82-
"hidden": ui.Hidden,
83-
"search": ui.Search,
84-
"field": ui.NewField,
85-
86-
// Alerts
87-
"alert": ui.NewAlert,
88-
"alertInfo": ui.AlertInfoMsg,
89-
"alertSuccess": ui.AlertSuccessMsg,
90-
"alertWarning": ui.AlertWarningMsg,
91-
"alertDanger": ui.AlertDangerMsg,
92-
"alertError": ui.AlertErrorMsg,
93-
"flash": ui.NewFlash,
94-
"toast": ui.NewToast,
9535
}
9636
}
9737

0 commit comments

Comments
 (0)