Merge pull request #122 from polkadot-api/vo/thousands
feat: add thousands separators to view and edit components
This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+11
-4
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user