Open dicash opened 8 months ago
You can actually share the ref to be used both for masking and form purposes. Can you share some code to understand more your use case please?
It's very complicated setup unfortunately with nextjs / SSR, so we are forced to use ref forwarded from parent component, so we don't have access to it...
It's very complicated setup unfortunately with nextjs / SSR, so we are forced to use ref forwarded from parent component, so we don't have access to it unfortunately...
I'm sorry. I don't want to say that there is always a way if I didn't see the code, but 99% of times it's easily doable. But If you can't even share a simple reproduction/example of what kind of structure you're talking about, this issue should be closed.
Ah, and btw, it hasn't got anything to do with SSR and/or Next.js, this is just plain react.
If I had to guess, you're using react-hook-form and need the component's ref, in that case a simple
const { onChange, onBlur, name, ref } = register('firstName');
<Input
...
ref={(node) => {
ref.current = node;
// use node however you want, with useImperativeHandle for example
}}
/>
Of course this can be done from input's parent also once you forward its ref as it seems you're already doing.
Ok here's snippet based on shadcn:
// input-currency
import * as React from 'react'
import { useId } from 'react'
import { Input, InputFieldProps, InputProps } from './input'
import { InputMask, InputMaskProps } from './input-mask'
const InputCurrency = React.forwardRef<
HTMLInputElement,
Omit<InputProps, 'value' | 'defaultValue'> &
InputProps &
InputFieldProps &
InputMaskProps & {
value?: string
currency?: string
defaultValue?: string
lang?: 'en' | 'fr'
max?: boolean
min?: boolean
}
>(
(
{
className,
type,
icon,
value,
label,
defaultValue,
max,
min,
currency = 'CAD',
...props
},
parentRef
) => {
let id = useId()
id = props.id || `currency-${id}`
const { mask, ...format } = {
mask: '$ num',
thousandsSeparator: ',',
radix: '.',
mapToRadix: [','],
}
return (
<InputMask
mask={mask}
label={label}
placeholder="$"
ref={parentRef}
lazy={false}
blocks={{
num: {
mask: Number,
expose: true,
scale: 2,
normalizeZeros: true,
padFractionalZeros: true,
autofix: true,
max,
min,
...format,
},
}}
inputMode="decimal"
defaultValue={defaultValue}
icon={currency}
{...props}
/>
)
}
)
// input-mask.tsx
import * as React from 'react'
import { Input, InputFieldProps, InputProps } from './input'
import IMask, { Definitions, InputMask as Mask, MaskedNumber } from 'imask'
export const radixOperator = (language?: string) => {
const value = new Intl.NumberFormat(language).format(0.11)
const radix = value[1]
return radix
}
export const thousandsSeparator = (language?: string) => {
const value = new Intl.NumberFormat(language).format(1000)
const thousandSeparator = value[1]
return thousandSeparator
}
export type InputMaskProps = {
icon?: React.ReactNode
mask?: string | NumberConstructor
blocks?: {}
definitions?: Definitions
displayChar?: string
overwrite?: 'shift'
lazy?: boolean
normalizeZeros?: boolean
padFractionalZeros?: boolean
autofix?: boolean
scale?: number
thousandsSeparator?: string
radix?: string
min?: string | number
max?: string | number
defaultValue?: string
onComplete?: (value: string, unmaskedValue: string) => void
onAccept?: (value: string, unmaskedValue: string) => void
}
const InputMask = React.forwardRef<HTMLInputElement, InputProps & InputFieldProps & InputMaskProps>(
(
{
mask,
displayChar,
overwrite,
lazy = true,
blocks,
definitions,
onComplete,
onAccept,
scale,
thousandsSeparator = '',
radix = ',',
min,
max,
defaultValue,
normalizeZeros,
autofix,
padFractionalZeros,
...props
},
parentRef
) => {
const masked = React.useRef<Mask<any>>()
const [input, setInput] = React.useState<HTMLInputElement | null>()
// NOTE: we can't use ref as dependency since it may trigger effect twice, which erases value.
// see https://legacy.reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node
React.useLayoutEffect(() => {
if (!input) return
masked.current = IMask<any>(input, {
mask: mask,
definitions,
displayChar,
overwrite,
lazy,
blocks,
scale,
thousandsSeparator,
radix,
min,
max,
normalizeZeros,
padFractionalZeros,
})
masked.current.value = (defaultValue ?? '') as string
// eg. when country changes - we have to update proper phone
if (masked.current.masked.isComplete)
onComplete?.(masked.current.value, masked.current.unmaskedValue)
return () => masked.current?.destroy()
}, [input, mask])
// we need to have an effect since onAccessCallback changes independently and more frequently than ref.current
React.useLayoutEffect(() => {
if (!masked.current) return
const { current } = masked
const onCompleteCallback = () => onComplete?.(current.value, current.unmaskedValue)
const onAcceptCallback = () => onAccept?.(current.value, current.unmaskedValue)
current.on('accept', onAcceptCallback)
current.on('complete', onCompleteCallback)
return () => {
current.off('complete', onCompleteCallback)
current.off('accept', onAcceptCallback)
}
}, [onComplete, onAccept, mask, input])
React.useImperativeHandle(parentRef, () => input as HTMLInputElement)
return <Input ref={(node) => setInput(node)} {...props} />
}
)
InputMask.displayName = 'InputMask'
export { InputMask }
// input.tsx
import * as React from 'react'
import clsx from 'clsx'
import { useId } from 'react'
import { IconWarning } from './icon'
export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> {
value?: string | null
}
const Input = React.forwardRef<
HTMLInputElement,
InputFieldProps & InputProps & { icon?: React.ReactNode; rounded?: boolean }
>(({ className, type, icon, invalid, before, rounded, ...props }, parentRef) => {
let id = useId()
id = props.id || `input-${id}`
return (
<InputField
{...{
id,
...props,
invalid,
before,
icon: (
<>
{icon}
{invalid ? <IconWarning size="lg" className="text-danger-600" /> : null}
</>
),
}}
>
<input
id={id}
type={type || 'text'}
className={clsx(
'peer flex h-14 w-full border bg-white pl-4 py-2 text-base file:border-0 file:bg-transparent file:text-base file:font-medium placeholder-neutral-400 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-offset-0 focus-visible:border disabled:cursor-not-allowed border-neutral-400 disabled:border-neutral-300 disabled:text-neutral-400',
invalid && icon ? 'pr-20' : invalid || icon ? 'pr-12' : 'pr-4',
before ? '!pl-12' : '',
props.label ? 'pt-6' : '',
rounded ? 'rounded-full' : 'rounded-md',
invalid
? '!border-danger-600 focus-visible:ring-danger-200'
: 'focus-visible:ring-primary-300 focus-visible:border-primary-500',
className
)}
ref={parentRef}
placeholder=" "
{...props}
value={props.value === null ? (props.defaultValue == null ? '' : undefined) : props.value}
/>
</InputField>
)
})
Input.displayName = 'Input'
export interface InputFieldProps {
id?: string
className?: string
label?: React.ReactNode
info?: React.ReactNode
icon?: React.ReactNode
before?: React.ReactNode
invalid?: boolean
disabled?: boolean
placeholder?: string
children?: React.ReactNode
value?: string | number | null | undefined
}
// takes input element and wraps with error, support, icon etc.
export const InputField = ({
id,
className,
info,
invalid,
label,
icon,
children,
disabled,
placeholder,
before,
...props
}: InputFieldProps) => {
return (
<div className={clsx('relative w-full items-center gap-1.5 group', className)}>
{before ? (
<div className="absolute left-4 h-14 top-0 flex flex-row gap-2 items-center text-neutral-600 z-10">
{before}
</div>
) : null}
{children}
{label ? (
<label
className={clsx(
'absolute left-4 right-8 transition-all top-2 truncate max-w-full cursor-text',
!id && 'pointer-events-none',
invalid
? '!text-danger-600 peer-focus:text-danger-600'
: disabled
? '!text-neutral-400'
: 'text-neutral-600 peer-focus:text-primary-500',
// NOTE: we suppose if props have value, then input is controllable and we handle label differently (see select vs text/input)
'value' in props
? props.value == null
? placeholder
? 'text-xs'
: 'top-4 text-base'
: 'text-xs peer-focus:top-2 peer-focus:text-xs'
: !placeholder
? 'peer-focus:top-2 peer-focus:text-xs text-xs peer-placeholder-shown:top-4 peer-placeholder-shown:text-base'
: 'text-xs'
)}
htmlFor={id}
>
{label}
</label>
) : null}
{icon ? (
<div className="absolute right-4 h-14 top-0 flex flex-row gap-2 items-center text-neutral-600">
{icon}
</div>
) : null}
{info ? (
<p
className={clsx(
'text-xs mt-1',
disabled ? 'text-neutral-400' : invalid ? 'text-danger-600' : 'text-neutral-600'
)}
>
{info}
</p>
) : null}
</div>
)
}
export { Input }
I will try to shave off code to get more minimal sample.
Ok here's snippet based on shadcn: ... I will try to shave off code to get more minimal sample.
I've got your sample. I need to create a small reproduction, removing a lot of stuff. Btw other than being noisy, unless you've stripped some stuff, it also contains useless pieces such as the empty useImperativeHandle.
I'll update you 🙂
@giacomocerquone Has there been any progress on this? I have the same requirements as @dicash: Would it be possible to make the lazy prop react to changes, so that the mask is only shown, when the input has focus?
Here is a very simple example of the scenario: https://stackblitz.com/edit/vue3-vite-starter-as5sug?file=src%2FApp.vue
What problem you are trying to solve?
We're using imask as part of mui-like input component, it has placeholder displayed by default, and upon click moves it up and shows currency input.
https://github.com/uNmAnNeR/imaskjs/assets/136486874/379bdacd-4ca7-4a97-b6e9-61cfd8f07e2d
We'd like to use imask
lazy=false
to display dollar sign when input is focused, and uselazy=true
when it's not.https://github.com/uNmAnNeR/imaskjs/assets/136486874/13f50339-cce4-45a6-b6b8-bf46757d8c70
Describe the solution you'd like
We'd like to have
lazy='focus'
mode, that enables lazy mode only when the input is focused, and can display native placeholder otherwise.Describe alternatives you've considered
We considered exposing imask instance via imperative handle to parent component with manual control of onfocus/onblur, but we preserve
ref
for external form validator so we cannot use it unfortunately.