brf/client/shared/components/input_factory.tsx
2025-12-13 21:12:08 +01:00

160 lines
4.3 KiB
TypeScript

import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
import escapeRegex from '../utils/escape_regex.ts'
type Styles<C extends string, D extends string, S extends string> = {
base?: string
touched?: string
element?: string
label?: string
statusIcon?: string
noMargin?: string
icon?: string
labelIcon?: string
center?: string
} & Record<C, string> &
Record<D, string> &
Record<S, string>
type Props<C extends string, D extends string, S extends string> = {
autoComplete?: string
autoFocus?: boolean
center?: boolean
className?: string
classNames?: Partial<Styles<C, D, S>>
color: C
defaultValue?: string
design: D
disabled?: boolean
icon: string
label: string
name: string
noMargin?: boolean
pattern?: string
placeholder?: string
required?: boolean
sameAs?: string
showValidity?: boolean
size?: S
type?: string
value?: any
}
export default function inputFactory<C extends string = never, D extends string = never, S extends string = never>(
styles: Styles<C, D, S>,
) {
const Input: FunctionComponent<Props<C, D, S>> = ({
autoComplete,
autoFocus,
center,
className,
classNames,
color,
defaultValue,
design,
disabled,
icon,
label,
name,
noMargin,
pattern,
placeholder,
required,
sameAs,
size,
type = 'text',
value,
showValidity = true,
}) => {
const [touched, setTouched] = useState(false)
const inputRef = useRef<HTMLInputElement>(null)
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
function onInput(e: Event) {
inputRef.current!.pattern = escapeRegex((e.target! as HTMLInputElement).value)
}
const form = inputRef.current!.form!
const resetTouched = setTouched.bind(null, false)
form.addEventListener('reset', resetTouched)
let sameAsInput: HTMLInputElement
if (sameAs) {
sameAsInput = form[sameAs]
sameAsInput.addEventListener('input', onInput)
}
return () => {
form.removeEventListener('reset', resetTouched)
if (sameAsInput) sameAsInput.removeEventListener('input', onInput)
}
}, [])
useEffect(() => {
if (autoFocus) inputRef.current!.focus()
}, [autoFocus])
const id = styles.element + '_' + name
return (
<div
className={cn(
styles.base,
design && cn(styles[design], classNames?.[design]),
color && cn(styles[color], classNames?.[color]),
size && cn(styles[size], classNames?.[size]),
noMargin && cn(styles.noMargin, classNames?.noMargin),
touched && cn(styles.touched, classNames?.touched),
center && cn(styles.center, classNames?.center),
className,
)}
>
{icon && (styles.icon || classNames?.icon) && (
<div className={cn(styles.icon, classNames?.icon)} style={{ maskImage: icon }} />
)}
<input
autoComplete={autoComplete}
autoFocus={autoFocus}
className={cn(styles.element, classNames?.element)}
defaultValue={
defaultValue && type === 'datetime-local' ? new Date(defaultValue).toLocaleString('sv-SE') : defaultValue
}
disabled={disabled}
id={id}
name={name}
onBlur={onBlur}
pattern={pattern}
placeholder={placeholder ?? label}
ref={inputRef}
required={required}
type={type}
value={value}
/>
{showValidity && <i className={cn(styles.statusIcon, classNames?.statusIcon)} />}
{/**
* <label> comes after <input> to be able to use next sibling CSS selector
* (see public input.module.scss). Use `flexbox` and `order` if <label> needs to be
* displayed before <input> (see admin input.module.scss)
*/}
{label && (
<label className={cn(styles.label, classNames?.label)} htmlFor={id}>
{icon && (styles.labelIcon || classNames?.labelIcon) && (
<div className={cn(styles.labelIcon, classNames?.labelIcon)} style={{ maskImage: icon }} />
)}
{label}
</label>
)}
</div>
)
}
return Input
}