Merge pull request #122 from polkadot-api/vo/thousands

feat: add thousands separators to view and edit components
This commit is contained in:
Victor Oliva
2026-03-16 11:33:40 +01:00
committed by GitHub
5 changed files with 232 additions and 52 deletions
+10 -1
View File
@@ -11,7 +11,7 @@ index 44b3fdc1f5d619833af09f0f916a685e55fcc9c8..7054064cb847e7d69eac8ddad21b5593
collapseStringsAfterLength?: number;
collapseStringMode?: 'directly' | 'word' | 'address';
diff --git a/dist/es/index.mjs b/dist/es/index.mjs
index ee2e3dee5780764f111f8761442e45014d8f050e..70adf8d3cad13239dfc255d2ae3d87225eb5d0b1 100644
index ee2e3dee5780764f111f8761442e45014d8f050e..8e92ebf3f7ed6c22c883c68deed5c4e2612bbdcc 100644
--- a/dist/es/index.mjs
+++ b/dist/es/index.mjs
@@ -495,7 +495,7 @@ function LargeArrayNode({ originNode, node, depth, index, deleteHandle: _deleteS
@@ -64,6 +64,15 @@ index ee2e3dee5780764f111f8761442e45014d8f050e..70adf8d3cad13239dfc255d2ae3d8722
let customReturn;
if (typeof customizeNode === 'function')
customReturn = safeCall(customizeNode, [{ node, depth, indexOrName }]);
@@ -878,7 +879,7 @@ function JsonNode({ node, depth, deleteHandle: _deleteHandle, indexOrName, paren
className += ' ' + customReturn.className;
if (deleting)
className += ' json-view--deleting';
- let displayValue = String(node);
+ let displayValue = type === 'number' || type === 'bigint' ? node.toLocaleString() : String(node);
if (type === 'bigint')
displayValue += 'n';
const EditingElement = useMemo(() => (jsx("span", { contentEditable: true, className: className, dangerouslySetInnerHTML: { __html: type === 'string' ? `"${displayValue}"` : displayValue }, ref: valueRef, onKeyDown: handleKeyDown })), [displayValue, type, handleKeyDown]);
@@ -920,12 +921,13 @@ const JsonViewContext = createContext({
DoneComponent: undefined,
CustomOperation: undefined
+11 -4
View File
@@ -6,7 +6,7 @@ settings:
patchedDependencies:
react18-json-view:
hash: 18eb0a02f9279644adf5cb880895a0c85bd6a84bb945850a7d29833aa7c4c00c
hash: 962696fac5fe7ad3e94fd36dad07521c6c3eafd52f219ab335d52438387f6b66
path: patches/react18-json-view.patch
importers:
@@ -129,7 +129,7 @@ importers:
version: 4.18.3(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
react18-json-view:
specifier: ^0.2.9
version: 0.2.9(patch_hash=18eb0a02f9279644adf5cb880895a0c85bd6a84bb945850a7d29833aa7c4c00c)(react@19.2.4)
version: 0.2.9(patch_hash=962696fac5fe7ad3e94fd36dad07521c6c3eafd52f219ab335d52438387f6b66)(react@19.2.4)
rxjs:
specifier: ^7.8.2
version: 7.8.2
@@ -2385,6 +2385,9 @@ packages:
'@types/node@25.3.5':
resolution: {integrity: sha512-oX8xrhvpiyRCQkG1MFchB09f+cXftgIXb3a7UUa4Y3wpmZPw5tyZGTLWhlESOLq1Rq6oDlc8npVU2/9xiCuXMA==}
'@types/node@25.4.0':
resolution: {integrity: sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw==}
'@types/normalize-package-data@2.4.4':
resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==}
@@ -5147,7 +5150,7 @@ snapshots:
'@polkadot-api/wasm-executor': 0.2.3
'@polkadot-api/ws-middleware': 0.2.2(rxjs@7.8.2)
'@polkadot-api/ws-provider': 0.8.0(rxjs@7.8.2)
'@types/node': 25.3.5
'@types/node': 25.4.0
commander: 14.0.3
execa: 9.6.1
fs.promises.exists: 1.1.4
@@ -7549,6 +7552,10 @@ snapshots:
dependencies:
undici-types: 7.18.2
'@types/node@25.4.0':
dependencies:
undici-types: 7.18.2
'@types/normalize-package-data@2.4.4': {}
'@types/prop-types@15.7.15': {}
@@ -9465,7 +9472,7 @@ snapshots:
react: 19.2.4
react-dom: 19.2.4(react@19.2.4)
react18-json-view@0.2.9(patch_hash=18eb0a02f9279644adf5cb880895a0c85bd6a84bb945850a7d29833aa7c4c00c)(react@19.2.4):
react18-json-view@0.2.9(patch_hash=962696fac5fe7ad3e94fd36dad07521c6c3eafd52f219ab335d52438387f6b66)(react@19.2.4):
dependencies:
copy-to-clipboard: 3.3.3
react: 19.2.4
+78 -45
View File
@@ -1,59 +1,92 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { FC } from "react"
import { IntegerInput } from "@/components/IntegerInput"
import {
EditBigNumber,
EditBigNumberInterface,
EditNumber,
EditNumberInterface,
EditPrimitiveComponentProps,
NOTIN,
} from "@polkadot-api/react-builder"
import { TextInputField } from "./codec-components"
import { useSynchronizeInput } from "@/components/useSynchroniseInput"
import { useState } from "react"
import { twMerge } from "tailwind-merge"
const NumericEdit =
<T extends number | bigint>(
parseValue: (value: string) => T | NOTIN,
): FC<T extends bigint ? EditBigNumberInterface : EditNumberInterface> =>
({ value, onValueChanged, numType }) => {
const getValidation = (value: string) => {
const sign = numType.substring(0, 1)
const size = Number(numType.substring(1))
function NumericEdit({
value,
onValueChanged,
numType,
}: EditPrimitiveComponentProps<bigint> & {
numType:
| "u8"
| "u16"
| "u32"
| "i8"
| "i16"
| "i32"
| "u64"
| "u128"
| "u256"
| "i64"
| "i128"
| "i256"
}) {
const getValidation = () => {
const sign = numType.substring(0, 1)
const size = Number(numType.substring(1))
const maxValue = sign === "i" ? 2 ** size / 2 - 1 : 2 ** size - 1
const minValue = sign === "i" ? -(2 ** size / 2) : 0
const maxValue = sign === "i" ? 2 ** size / 2 - 1 : 2 ** size - 1
const minValue = sign === "i" ? -(2 ** size / 2) : 0
const parsed = parseValue(value)
if (parsed === NOTIN) return null
if (parsed > maxValue) return "Too high. Max is " + maxValue
if (parsed < minValue) return "Too low. Min is " + minValue
return null
}
if (value === NOTIN) return null
if (value > maxValue) return "Too high. Max is " + maxValue
if (value < minValue) return "Too low. Min is " + minValue
return null
}
const [hasBlurred, blur] = useState(false)
const [inputValue, setInputValue] = useSynchronizeInput(
value as any,
onValueChanged as any,
(v) => (getValidation(v) ? NOTIN : parseValue(v)),
)
const warn = value === NOTIN
const error = getValidation()
return (
<TextInputField
value={inputValue}
onChange={setInputValue}
return (
<div className="flex gap-1 items-center">
<IntegerInput
className={twMerge(
"px-4 py-2 border border-border leading-tight text-foreground rounded bg-input",
warn ? "border-yellow-400" : null,
error ? "border-red-500" : null,
)}
value={value === NOTIN ? null : value}
placeholder={numType}
warn={parseValue(inputValue) === NOTIN}
error={getValidation(inputValue)}
onChange={(value) => onValueChanged(value == null ? NOTIN : value)}
onFocus={() => blur(false)}
onBlur={() => blur(true)}
/>
)
}
{(hasBlurred || error) &&
(typeof error === "string" ? (
<span className="text-red-600 text-sm">{error}</span>
) : typeof warn === "string" ? (
<span className="text-orange-400 text-sm">{warn}</span>
) : null)}
</div>
)
}
export const CNumber: EditNumber = NumericEdit((value: string) => {
const parsed = Number(value)
return value.trim() === "" || !Number.isSafeInteger(parsed) ? NOTIN : parsed
})
export const CBigNumber: EditBigNumber = NumericEdit((value: string) => {
try {
return value.trim() === "" ? NOTIN : BigInt(value)
} catch (_) {
return NOTIN
}
})
export const CBigNumber: EditBigNumber = NumericEdit
const toBigInt = (v: number | NOTIN) => (v === NOTIN ? NOTIN : BigInt(v))
const toNumber = (v: bigint | NOTIN) => (v === NOTIN ? NOTIN : Number(v))
export const CNumber: EditNumber = (props) => {
const decode: EditBigNumberInterface["decode"] = (v) =>
toBigInt(props.decode(v))
const onValueChanged: EditBigNumberInterface["onValueChanged"] = (value) =>
props.onValueChanged(toNumber(value))
return props.type === "blank" ? (
<NumericEdit {...props} decode={decode} onValueChanged={onValueChanged} />
) : (
<NumericEdit
{...props}
decode={decode}
onValueChanged={onValueChanged}
value={BigInt(props.value)}
/>
)
}
@@ -37,7 +37,9 @@ export const CResult: ViewResult = ({ value, inner }) => {
}
export const CStr: ViewStr = ({ value }) => <div>{value}</div>
export const CNumber: ViewNumber = ({ value }) => <div>{value}</div>
export const CNumber: ViewNumber = ({ value }) => (
<div>{value.toLocaleString()}</div>
)
export const CBitSeq: ViewBitSeq = ({ value }) => (
<div>
{value
@@ -46,5 +48,5 @@ export const CBitSeq: ViewBitSeq = ({ value }) => (
</div>
)
export const CBigNumber: ViewBigNumber = ({ value }) => (
<div>{String(value)}</div>
<div>{value.toLocaleString()}</div>
)
+129
View File
@@ -0,0 +1,129 @@
import { useState, useCallback, useEffect, useRef, FC } from "react"
function formatWithSeparators(num: bigint): string {
const str = num.toString()
const isNegative = str.startsWith("-")
const digits = isNegative ? str.slice(1) : str
const formatted = digits.replace(/\B(?=(\d{3})+(?!\d))/g, ",")
return isNegative ? "-" + formatted : formatted
}
function parseBigInt(str: string): bigint | null {
const cleaned = str.replace(/,/g, "")
if (cleaned === "" || cleaned === "-") return null
try {
return BigInt(cleaned)
} catch {
return null
}
}
export const IntegerInput: FC<{
value?: bigint | null
defaultValue?: bigint
onChange?: (value: bigint | null) => void
className?: string
placeholder?: string
onFocus?: () => void
onBlur?: () => void
}> = ({
value: controlledValue,
defaultValue,
onChange,
onFocus,
onBlur,
...props
}) => {
const isControlled = controlledValue !== undefined
const [internalValue, setInternalValue] = useState<bigint | null>(
defaultValue ?? null,
)
const [isFocused, setIsFocused] = useState(false)
const [rawText, setRawText] = useState<string>("")
const currentValue = isControlled ? controlledValue : internalValue
// Sync rawText when controlled value changes externally while not focused
const prevControlledValue = useRef(controlledValue)
useEffect(() => {
if (
isControlled &&
!isFocused &&
controlledValue !== prevControlledValue.current
) {
// Value changed externally, no need to update rawText since we show formatted when not focused
}
prevControlledValue.current = controlledValue
}, [controlledValue, isControlled, isFocused])
const handleFocus = () => {
setIsFocused(true)
// When focusing, show raw digits (no separators)
setRawText(currentValue !== null ? currentValue.toString() : "")
onFocus?.()
}
const handleBlur = () => {
setIsFocused(false)
// Clean up rawText - parse and re-stringify to normalize
const parsed = parseBigInt(rawText)
if (parsed !== null) {
setRawText(parsed.toString())
}
onBlur?.()
}
const handleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
// Exclude thousands separators when pasting
const input = e.target.value.replace(/[,. ']/g, "")
// Allow empty input
if (input === "") {
setRawText("")
if (!isControlled) setInternalValue(null)
onChange?.(null)
return
}
// Allow just a minus sign (typing in progress)
if (input === "-") {
setRawText("-")
if (!isControlled) setInternalValue(null)
onChange?.(null)
return
}
// Only allow digits with optional leading minus
if (!/^-?\d+$/.test(input)) {
return // reject invalid input
}
setRawText(input)
const parsed = parseBigInt(input)
if (!isControlled) setInternalValue(parsed)
onChange?.(parsed)
},
[isControlled, onChange],
)
// Determine display value
const displayValue = isFocused
? rawText
: currentValue !== null
? formatWithSeparators(currentValue)
: ""
return (
<input
type="text"
inputMode="numeric"
value={displayValue}
onFocus={handleFocus}
onBlur={handleBlur}
onChange={handleChange}
{...props}
/>
)
}