initial port of the UI kit
This commit is contained in:
306
go/webui/formatters.go
Normal file
306
go/webui/formatters.go
Normal file
@@ -0,0 +1,306 @@
|
||||
// Port of web/kit/Formatters.ts.
|
||||
package webui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formattersNonDigit matches every non-digit rune (the JS /\D/g).
|
||||
var formattersNonDigit = regexp.MustCompile(`\D`)
|
||||
|
||||
// formattersDigits strips every non-digit character (mirrors String(n).replace(/\D/g, "")).
|
||||
func formattersDigits(s string) string {
|
||||
return formattersNonDigit.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
// formattersStringify mimics JavaScript's String(v) for the `string | number`
|
||||
// union accepted by several formatters. Go has no union type, so these take
|
||||
// `any` and stringify int / float / string inputs the way JS would.
|
||||
func formattersStringify(v any) string {
|
||||
switch n := v.(type) {
|
||||
case string:
|
||||
return n
|
||||
case int:
|
||||
return strconv.Itoa(n)
|
||||
case int8, int16, int32, int64:
|
||||
return fmt.Sprintf("%d", n)
|
||||
case uint, uint8, uint16, uint32, uint64:
|
||||
return fmt.Sprintf("%d", n)
|
||||
case float32:
|
||||
return strconv.FormatFloat(float64(n), 'f', -1, 32)
|
||||
case float64:
|
||||
return strconv.FormatFloat(n, 'f', -1, 64)
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
// formattersPadStartZero left-pads s with '0' up to length (the JS padStart(n, "0")).
|
||||
func formattersPadStartZero(s string, length int) string {
|
||||
if len(s) >= length {
|
||||
return s
|
||||
}
|
||||
return strings.Repeat("0", length-len(s)) + s
|
||||
}
|
||||
|
||||
// formattersParseInt approximates JS parseInt(s, 10): optional sign then leading
|
||||
// decimal digits. JS would yield NaN for non-numeric input; here we return 0.
|
||||
func formattersParseInt(s string) int {
|
||||
s = strings.TrimSpace(s)
|
||||
i := 0
|
||||
neg := false
|
||||
if i < len(s) && (s[i] == '+' || s[i] == '-') {
|
||||
neg = s[i] == '-'
|
||||
i++
|
||||
}
|
||||
start := i
|
||||
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
|
||||
i++
|
||||
}
|
||||
if start == i {
|
||||
return 0
|
||||
}
|
||||
n, err := strconv.Atoi(s[start:i])
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
if neg {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// FormatPhoneNumber formats a number as a US phone number: (XXX) XXX-XXXX.
|
||||
func FormatPhoneNumber(number any) string {
|
||||
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 10)[:10]
|
||||
areaCode := digits[0:3]
|
||||
centralOfficeCode := digits[3:6]
|
||||
lineNumber := digits[6:10]
|
||||
return "(" + areaCode + ") " + centralOfficeCode + "-" + lineNumber
|
||||
}
|
||||
|
||||
// FormatZipCode formats a number as a US zip code (5 or 9 digits).
|
||||
func FormatZipCode(number any) string {
|
||||
num := formattersParseInt(formattersStringify(number))
|
||||
|
||||
if num <= 99999 {
|
||||
return formattersPadStartZero(strconv.Itoa(num), 5)
|
||||
}
|
||||
|
||||
digits := formattersPadStartZero(strconv.Itoa(num), 9)
|
||||
zipCode := digits[0:5]
|
||||
plus4 := digits[5:9]
|
||||
return zipCode + "-" + plus4
|
||||
}
|
||||
|
||||
// FormatTaxID formats a number as a US Tax ID (EIN): XX-XXXXXXX.
|
||||
func FormatTaxID(number any) string {
|
||||
digits := formattersPadStartZero(formattersDigits(formattersStringify(number)), 9)[:9]
|
||||
prefix := digits[0:2]
|
||||
identifier := digits[2:9]
|
||||
return prefix + "-" + identifier
|
||||
}
|
||||
|
||||
// formattersGroupThousands inserts ',' every three digits from the right of an
|
||||
// integer-digit string (no sign, no fraction).
|
||||
func formattersGroupThousands(intDigits string) string {
|
||||
n := len(intDigits)
|
||||
if n <= 3 {
|
||||
return intDigits
|
||||
}
|
||||
var b strings.Builder
|
||||
pre := n % 3
|
||||
if pre > 0 {
|
||||
b.WriteString(intDigits[:pre])
|
||||
}
|
||||
for i := pre; i < n; i += 3 {
|
||||
if b.Len() > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
b.WriteString(intDigits[i : i+3])
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// formattersFormatGrouped renders n like Intl.NumberFormat("en-US") with the
|
||||
// given minimum/maximum fraction digits: rounds to maxFrac, trims trailing zeros
|
||||
// down to minFrac, and groups the integer part with commas.
|
||||
func formattersFormatGrouped(n float64, minFrac, maxFrac int) string {
|
||||
neg := math.Signbit(n)
|
||||
s := strconv.FormatFloat(math.Abs(n), 'f', maxFrac, 64)
|
||||
|
||||
intPart, fracPart := s, ""
|
||||
if dot := strings.IndexByte(s, '.'); dot >= 0 {
|
||||
intPart = s[:dot]
|
||||
fracPart = s[dot+1:]
|
||||
}
|
||||
|
||||
for len(fracPart) > minFrac && strings.HasSuffix(fracPart, "0") {
|
||||
fracPart = fracPart[:len(fracPart)-1]
|
||||
}
|
||||
|
||||
intPart = formattersGroupThousands(intPart)
|
||||
res := intPart
|
||||
if len(fracPart) > 0 {
|
||||
res += "." + fracPart
|
||||
}
|
||||
|
||||
// Only keep the sign when the rounded result is actually non-zero (avoids "-0").
|
||||
if neg && strings.ContainsFunc(res, func(r rune) bool { return r >= '1' && r <= '9' }) {
|
||||
res = "-" + res
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
// FormatNumber formats a number with US thousands separators (Intl.NumberFormat
|
||||
// "en-US" defaults: 0 minimum and 3 maximum fraction digits).
|
||||
func FormatNumber(number float64) string {
|
||||
return formattersFormatGrouped(number, 0, 3)
|
||||
}
|
||||
|
||||
// FormatDecimal formats a number with US thousands separators and a fixed number
|
||||
// of fraction digits (default 2). Go has no default parameters, so decimalPlaces
|
||||
// is an optional variadic argument.
|
||||
func FormatDecimal(number float64, decimalPlaces ...int) string {
|
||||
dp := 2
|
||||
if len(decimalPlaces) > 0 {
|
||||
dp = decimalPlaces[0]
|
||||
}
|
||||
return formattersFormatGrouped(number, dp, dp)
|
||||
}
|
||||
|
||||
// State Code Utilities
|
||||
|
||||
var formattersStateCodeMap = map[string]string{
|
||||
"Alabama": "AL", "Alaska": "AK", "Arizona": "AZ", "Arkansas": "AR", "California": "CA",
|
||||
"Colorado": "CO", "Connecticut": "CT", "Delaware": "DE", "District of Columbia": "DC", "Florida": "FL",
|
||||
"Georgia": "GA", "Hawaii": "HI", "Idaho": "ID", "Illinois": "IL", "Indiana": "IN",
|
||||
"Iowa": "IA", "Kansas": "KS", "Kentucky": "KY", "Louisiana": "LA", "Maine": "ME",
|
||||
"Maryland": "MD", "Massachusetts": "MA", "Michigan": "MI", "Minnesota": "MN", "Mississippi": "MS",
|
||||
"Missouri": "MO", "Montana": "MT", "Nebraska": "NE", "Nevada": "NV", "New Hampshire": "NH",
|
||||
"New Jersey": "NJ", "New Mexico": "NM", "New York": "NY", "North Carolina": "NC", "North Dakota": "ND",
|
||||
"Ohio": "OH", "Oklahoma": "OK", "Oregon": "OR", "Pennsylvania": "PA", "Puerto Rico": "PR",
|
||||
"Rhode Island": "RI", "South Carolina": "SC", "South Dakota": "SD", "Tennessee": "TN", "Texas": "TX",
|
||||
"Utah": "UT", "Vermont": "VT", "Virgin Islands": "VI", "Virginia": "VA", "Washington": "WA",
|
||||
"West Virginia": "WV", "Wisconsin": "WI", "Wyoming": "WY",
|
||||
}
|
||||
|
||||
// StateToStateCode resolves a state name to its two-letter code, matching the
|
||||
// exact name first and then case-insensitively. Returns "" when unknown.
|
||||
func StateToStateCode(state string) string {
|
||||
if code, ok := formattersStateCodeMap[state]; ok {
|
||||
return code
|
||||
}
|
||||
|
||||
stateLower := strings.ToLower(state)
|
||||
for stateName, code := range formattersStateCodeMap {
|
||||
if strings.ToLower(stateName) == stateLower {
|
||||
return code
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
// StateCodeToState resolves a two-letter code to its state name. Returns "" when unknown.
|
||||
func StateCodeToState(code string) string {
|
||||
up := strings.ToUpper(code)
|
||||
for state, stateCode := range formattersStateCodeMap {
|
||||
if stateCode == up {
|
||||
return state
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsValidStateCode reports whether code is a known two-letter state code.
|
||||
func IsValidStateCode(code string) bool {
|
||||
up := strings.ToUpper(code)
|
||||
for _, stateCode := range formattersStateCodeMap {
|
||||
if stateCode == up {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// formattersDateLayouts are the layouts tried when coercing a string into a
|
||||
// time.Time, standing in for JS `new Date(string)` (which cannot be reproduced
|
||||
// exactly). Ordered most-specific first.
|
||||
var formattersDateLayouts = []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02",
|
||||
"2006/01/02",
|
||||
"01/02/2006",
|
||||
time.RFC1123Z,
|
||||
time.RFC1123,
|
||||
time.ANSIC,
|
||||
}
|
||||
|
||||
// formattersToTime coerces the `string | Date` union into a time.Time. The bool
|
||||
// is false when a string could not be parsed (JS would produce "Invalid Date").
|
||||
func formattersToTime(date any) (time.Time, bool) {
|
||||
switch d := date.(type) {
|
||||
case time.Time:
|
||||
return d, true
|
||||
case string:
|
||||
for _, layout := range formattersDateLayouts {
|
||||
if t, err := time.Parse(layout, d); err == nil {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return time.Time{}, false
|
||||
default:
|
||||
return time.Time{}, false
|
||||
}
|
||||
}
|
||||
|
||||
// FormatDate formats a date as MM/DD/YYYY. Returns "" for an unparseable string.
|
||||
func FormatDate(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("01/02/2006")
|
||||
}
|
||||
|
||||
// FormatDateLong formats a date as e.g. "January 2, 2006". Returns "" for an
|
||||
// unparseable string.
|
||||
func FormatDateLong(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("January 2, 2006")
|
||||
}
|
||||
|
||||
// FormatDateTime formats a date+time as e.g. "01/02/2006, 3:04 PM". Returns ""
|
||||
// for an unparseable string.
|
||||
func FormatDateTime(date any) string {
|
||||
d, ok := formattersToTime(date)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return d.Format("01/02/2006, 3:04 PM")
|
||||
}
|
||||
|
||||
// FormatPercent formats a value as a percentage with a fixed number of decimal
|
||||
// places. When isDecimal is true the value is treated as a fraction (0-1) and
|
||||
// scaled by 100. Go has no default parameters, so decimalPlaces and isDecimal
|
||||
// (defaults 2 and false in the TS source) are required here.
|
||||
func FormatPercent(value float64, decimalPlaces int, isDecimal bool) string {
|
||||
percent := value
|
||||
if isDecimal {
|
||||
percent = value * 100
|
||||
}
|
||||
return strconv.FormatFloat(percent, 'f', decimalPlaces, 64) + "%"
|
||||
}
|
||||
Reference in New Issue
Block a user