Reactivity with shadcn-solid textfield

I created my own NumberInput using https://shadcn-solid.com/docs/components/textfield. I see they have a number field, but I wanted the more typical html number input. My implementation has 4 number inputs that are clamped based on min/max values. I'm testing by manually typing -1 in the field. Technically a 1 and then I'm adding a - to it since it won't input otherwise. The clamp works for 2 of the inputs, but fails for the other 2. It seems to be losing the reactivity somehow. I've tried adding createMemo around the value to ensure the reactivity is present although I don't believe that shouldn't be necessary since I'm passing the accessor. I might see about creating a minimal example for this, but for now here's the code for the NumberInput and the implementation using is.
import { createMemo } from 'solid-js'
import {
TextField,
TextFieldLabel,
TextFieldDescription,
TextFieldRoot,
} from '@/components/ui/textfield'

import type { Component, Accessor } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'

type NumberValue = number | Accessor<number>
type NumberSetter = ((v: number) => void) | ((prev: number) => number)

interface NumberInputProps {
id: string
label: JSX.Element
info?: JSX.Element
class?: string
min?: number
max?: number
value?: NumberValue
onInput?: NumberSetter
}

export const NumberInput: Component<NumberInputProps> = (props) => {
// const getValue = (): string => {
// console.log('getValue:', props.value)
// if (props.value === undefined) return ''
// return typeof props.value === 'function' ? props.value().toString() : props.value.toString()
// }
const value = createMemo(() => {
console.log('getValue:', typeof props.value === 'function' ? props.value() : props.value)
if (props.value === undefined) return ''
return typeof props.value === 'function' ? props.value().toString() : props.value.toString()
})

const clampValue = (value: number): number => {
let clampedValue = value

if (props.min !== undefined) {
clampedValue = Math.max(props.min, clampedValue)
}

if (props.max !== undefined) {
clampedValue = Math.min(props.max, clampedValue)
}

return clampedValue
}

const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement
const newValue = clampValue(Number(target.value))
console.log('setValue:', target.value, newValue)

props.onInput?.(newValue)
}

return (
<TextFieldRoot
class={props.class ?? 'w-24'}
// defaultValue={getValue()}
>
<TextFieldLabel for={props.id}>{props.label}</TextFieldLabel>
<TextField
id={props.id}
type="number"
min={props.min}
max={props.max}
value={value()}
onInput={handleInput}
/>
<TextFieldDescription>{props.info}</TextFieldDescription>
</TextFieldRoot>
)
}

export default NumberInput
import { createMemo } from 'solid-js'
import {
TextField,
TextFieldLabel,
TextFieldDescription,
TextFieldRoot,
} from '@/components/ui/textfield'

import type { Component, Accessor } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'

type NumberValue = number | Accessor<number>
type NumberSetter = ((v: number) => void) | ((prev: number) => number)

interface NumberInputProps {
id: string
label: JSX.Element
info?: JSX.Element
class?: string
min?: number
max?: number
value?: NumberValue
onInput?: NumberSetter
}

export const NumberInput: Component<NumberInputProps> = (props) => {
// const getValue = (): string => {
// console.log('getValue:', props.value)
// if (props.value === undefined) return ''
// return typeof props.value === 'function' ? props.value().toString() : props.value.toString()
// }
const value = createMemo(() => {
console.log('getValue:', typeof props.value === 'function' ? props.value() : props.value)
if (props.value === undefined) return ''
return typeof props.value === 'function' ? props.value().toString() : props.value.toString()
})

const clampValue = (value: number): number => {
let clampedValue = value

if (props.min !== undefined) {
clampedValue = Math.max(props.min, clampedValue)
}

if (props.max !== undefined) {
clampedValue = Math.min(props.max, clampedValue)
}

return clampedValue
}

const handleInput = (e: Event) => {
const target = e.target as HTMLInputElement
const newValue = clampValue(Number(target.value))
console.log('setValue:', target.value, newValue)

props.onInput?.(newValue)
}

return (
<TextFieldRoot
class={props.class ?? 'w-24'}
// defaultValue={getValue()}
>
<TextFieldLabel for={props.id}>{props.label}</TextFieldLabel>
<TextField
id={props.id}
type="number"
min={props.min}
max={props.max}
value={value()}
onInput={handleInput}
/>
<TextFieldDescription>{props.info}</TextFieldDescription>
</TextFieldRoot>
)
}

export default NumberInput
10 Replies
agentsmith
agentsmithOP3w ago
This is the implementation using the NumberInput. The trials and futurePoints inputs fail on the -1 test, but the other two work as expected.
import { createEffect, createSignal } from 'solid-js'
// import { Info } from 'lucide-solid'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card'
// import { Checkbox, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
// import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NumberInput } from '@/components/ui/NumberInput'
import MonteCarloChart from '@/components/charts/MonteCarlo'
import { setMonteCarloData, simulations } from '@/libs/monteCarlo'

import type { Component } from 'solid-js'
import type { TradeMetrics } from '@/libs/stats'

interface CardProps {
data: TradeMetrics | null
}

export const MonteCarloCard: Component<CardProps> = (props) => {
const [trials, setTrials] = createSignal(100)
const [futurePoints, setFuturePoints] = createSignal(100)
const [highRunsCnt, setHighRunsCnt] = createSignal(0)
const [lowRunsCnt, setLowRunsCnt] = createSignal(0)
// const [staticPoints, setStaticPoints] = createSignal(false)

const handleMonteCarloData = () => {
let simulationResult = simulations(
props.data?.netProfit ?? [],
trials(),
futurePoints(),
props.data?.startingEquity
)
// the data is already sorted by the last number in the array
// so we can just slice the array to remove the high and low points
// Slice off the high points (remove from the end)
const removeHigh = highRunsCnt()
// const highRemoved = simulationResult.slice(0, removeHigh)
simulationResult = simulationResult.slice(removeHigh)

// Slice off the low points (remove from the start)
const removeLow = lowRunsCnt()
// const lowRemoved = simulationResult.slice(simulationResult.length - removeLow)
simulationResult = simulationResult.slice(0, simulationResult.length - removeLow)
setMonteCarloData(() => simulationResult)
}

createEffect(() => {
handleMonteCarloData()
})

return (
<Card>
<CardHeader>
<CardTitle>Monte Carlo</CardTitle>
<div class="flex flex-wrap flex-row justify-end gap-2">
<NumberInput
id="trials"
label="Trials"
min={1}
value={trials}
onInput={setTrials}
/>
<NumberInput
id="futurePoints"
label="Future Points"
min={1}
value={futurePoints}
onInput={setFuturePoints}
/>
<NumberInput
class="w-36"
id="removeHighRuns"
label="Remove Best Trials"
min={0}
value={highRunsCnt}
onInput={setHighRunsCnt}
/>
<NumberInput
class="w-36"
id="removeLowRuns"
label="Remove Worst Trials"
min={0}
value={lowRunsCnt}
onInput={setLowRunsCnt}
/>
<Button
class="self-end"
style={{ 'margin-bottom': '0.3rem' }}
variant="default"
onClick={() => handleMonteCarloData()}
>
Rerun
</Button>
</div>
</CardHeader>
<CardContent>
<MonteCarloChart
data={props.data}
// staticPoints={staticPoints()}
/>
</CardContent>
</Card>
)
}

export default MonteCarloCard
import { createEffect, createSignal } from 'solid-js'
// import { Info } from 'lucide-solid'
import { Button } from '@/components/ui/button'
import { Card, CardHeader, CardContent, CardTitle } from '@/components/ui/card'
// import { Checkbox, CheckboxControl, CheckboxLabel } from '@/components/ui/checkbox'
// import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
import { NumberInput } from '@/components/ui/NumberInput'
import MonteCarloChart from '@/components/charts/MonteCarlo'
import { setMonteCarloData, simulations } from '@/libs/monteCarlo'

import type { Component } from 'solid-js'
import type { TradeMetrics } from '@/libs/stats'

interface CardProps {
data: TradeMetrics | null
}

export const MonteCarloCard: Component<CardProps> = (props) => {
const [trials, setTrials] = createSignal(100)
const [futurePoints, setFuturePoints] = createSignal(100)
const [highRunsCnt, setHighRunsCnt] = createSignal(0)
const [lowRunsCnt, setLowRunsCnt] = createSignal(0)
// const [staticPoints, setStaticPoints] = createSignal(false)

const handleMonteCarloData = () => {
let simulationResult = simulations(
props.data?.netProfit ?? [],
trials(),
futurePoints(),
props.data?.startingEquity
)
// the data is already sorted by the last number in the array
// so we can just slice the array to remove the high and low points
// Slice off the high points (remove from the end)
const removeHigh = highRunsCnt()
// const highRemoved = simulationResult.slice(0, removeHigh)
simulationResult = simulationResult.slice(removeHigh)

// Slice off the low points (remove from the start)
const removeLow = lowRunsCnt()
// const lowRemoved = simulationResult.slice(simulationResult.length - removeLow)
simulationResult = simulationResult.slice(0, simulationResult.length - removeLow)
setMonteCarloData(() => simulationResult)
}

createEffect(() => {
handleMonteCarloData()
})

return (
<Card>
<CardHeader>
<CardTitle>Monte Carlo</CardTitle>
<div class="flex flex-wrap flex-row justify-end gap-2">
<NumberInput
id="trials"
label="Trials"
min={1}
value={trials}
onInput={setTrials}
/>
<NumberInput
id="futurePoints"
label="Future Points"
min={1}
value={futurePoints}
onInput={setFuturePoints}
/>
<NumberInput
class="w-36"
id="removeHighRuns"
label="Remove Best Trials"
min={0}
value={highRunsCnt}
onInput={setHighRunsCnt}
/>
<NumberInput
class="w-36"
id="removeLowRuns"
label="Remove Worst Trials"
min={0}
value={lowRunsCnt}
onInput={setLowRunsCnt}
/>
<Button
class="self-end"
style={{ 'margin-bottom': '0.3rem' }}
variant="default"
onClick={() => handleMonteCarloData()}
>
Rerun
</Button>
</div>
</CardHeader>
<CardContent>
<MonteCarloChart
data={props.data}
// staticPoints={staticPoints()}
/>
</CardContent>
</Card>
)
}

export default MonteCarloCard
REEEEE
REEEEE3w ago
Can't see any issues at first glance, what exactly is the issue you're running into? I don't think I understand it yet
agentsmith
agentsmithOP3w ago
I have a clamp to enforce the min/max value set. Entering -1 should not be possible here. It works as expected for the Remove best/worst trials. I think I might have a logical error in the clamp code. Just getting back to it this morning. Seem to work as expected, but not if I use 1 as the min value.
No description
agentsmith
agentsmithOP3w ago
It's probably not a huge issue. The only way it can be done is by entering a 1 and then a - because you can directly type -1 and it only seems to fail in that case if your doing it for the same number at the specified min value. Even for 0.
No description
agentsmith
agentsmithOP3w ago
Alright, I don't think this is a real issue that would happen, but I wasn't seeing that yesterday. Still an oddity though. I've identified the issue and it's a reactive thing, but I don't know how to fix it yet. I can change the input value and when I do so it invokes the handle input which applies the clamp. This results in the underlying signal sticking to the value I expect. However, because the clamp results in the value being the same it doesn't trigger an update to re-render the input with the correct value. This seems like a fairly common use-case when using inputs. Is there a way to deal with this in solid?
REEEEE
REEEEE3w ago
You could add {equals: false} to the options for the signal that tracks the value
agentsmith
agentsmithOP3w ago
That looks like it should work. I think it's a bug/restriction in the shadcn-solid component. It does work as expect if I use that and a regular input. But the issue is still there if I use the shadcn-solid components.
import { createSignal } from 'solid-js'
import {
TextField,
TextFieldLabel,
TextFieldDescription,
TextFieldRoot,
} from '@/components/ui/textfield'

import type { Component } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'

interface NumberInputProps {
id: string
label: JSX.Element
info?: JSX.Element
class?: string
min?: number
max?: number
value?: number | (() => number)
onInput?: (value: number) => void
}

export const NumberInput: Component<NumberInputProps> = (props) => {
const [inputValue, setInputValue] = createSignal(
typeof props.value === 'function' ? props.value() : props.value,
{ equals: () => false }
)

// Clamping function to enforce min and max constraints
const clampValue = (value: number): number => {
let clamped = value
if (props.min !== undefined) {
clamped = Math.max(props.min, clamped)
}
if (props.max !== undefined) {
clamped = Math.min(props.max, clamped)
}
return clamped
}

const handleInput = (e: Event) => {
console.log('handleInput')
const target = e.target as HTMLInputElement
const val = Number(target.value)
const clamped = clampValue(val)
setInputValue(clamped) // Update local state to clamped value
props.onInput?.(clamped) // Update external signal
}

return (
<TextFieldRoot
class={props.class ?? 'w-24'}
defaultValue={inputValue()?.toString()}
>
<TextFieldLabel for={props.id}>{props.label}</TextFieldLabel>
<TextField
id={props.id}
type="number"
min={props.min}
max={props.max}
value={inputValue()}
onInput={handleInput}
/>
{props.info && <TextFieldDescription>{props.info}</TextFieldDescription>}
</TextFieldRoot>
// using this works as expected.
// <input
// id={props.id}
// type="number"
// min={props.min}
// max={props.max}
// value={inputValue()}
// onInput={handleInput}
// />
)
}

export default NumberInput
import { createSignal } from 'solid-js'
import {
TextField,
TextFieldLabel,
TextFieldDescription,
TextFieldRoot,
} from '@/components/ui/textfield'

import type { Component } from 'solid-js'
import type { JSX } from 'solid-js/jsx-runtime'

interface NumberInputProps {
id: string
label: JSX.Element
info?: JSX.Element
class?: string
min?: number
max?: number
value?: number | (() => number)
onInput?: (value: number) => void
}

export const NumberInput: Component<NumberInputProps> = (props) => {
const [inputValue, setInputValue] = createSignal(
typeof props.value === 'function' ? props.value() : props.value,
{ equals: () => false }
)

// Clamping function to enforce min and max constraints
const clampValue = (value: number): number => {
let clamped = value
if (props.min !== undefined) {
clamped = Math.max(props.min, clamped)
}
if (props.max !== undefined) {
clamped = Math.min(props.max, clamped)
}
return clamped
}

const handleInput = (e: Event) => {
console.log('handleInput')
const target = e.target as HTMLInputElement
const val = Number(target.value)
const clamped = clampValue(val)
setInputValue(clamped) // Update local state to clamped value
props.onInput?.(clamped) // Update external signal
}

return (
<TextFieldRoot
class={props.class ?? 'w-24'}
defaultValue={inputValue()?.toString()}
>
<TextFieldLabel for={props.id}>{props.label}</TextFieldLabel>
<TextField
id={props.id}
type="number"
min={props.min}
max={props.max}
value={inputValue()}
onInput={handleInput}
/>
{props.info && <TextFieldDescription>{props.info}</TextFieldDescription>}
</TextFieldRoot>
// using this works as expected.
// <input
// id={props.id}
// type="number"
// min={props.min}
// max={props.max}
// value={inputValue()}
// onInput={handleInput}
// />
)
}

export default NumberInput
REEEEE
REEEEE3w ago
You could try not using onInput and instead use onChange on the root @agentsmith Because Kobalte, the library shadcn-solid uses, does some input handling to keep the input in sync with the value which might be the cause of the issue you're seeing
agentsmith
agentsmithOP3w ago
I tried that originally, but I'll try again. Either way I'm about to just move on. It's mostly working. The app won't break because the value will be clamped even if the user can type in values outside of the min/max if they know how.
jer3m01
jer3m013w ago
What are your issues with NumberField? It handles everything you've listed for you and much more. It also assures you're following the correct accessibility patterns
Want results from more Discord servers?
Add your server