// 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) + "%" }