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

135 lines
3.4 KiB
TypeScript

import { h, type FunctionComponent } from 'preact'
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
import cn from 'classnames'
import mergeStyles from '../utils/merge_styles.ts'
import { Script } from '@fastify/type-provider-typebox'
type Styles<C extends string, D extends string, S extends string> = {
base?: string
touched?: string
element?: string
label?: string
icon?: string
} & Record<C, string> &
Record<D, string> &
Record<S, string>
type Options<C extends string, D extends string, S extends string> = {
styles: Styles<C, D, S>
}
type Props<C extends string, D extends string, S extends string> = {
autoFocus?: boolean
className?: string
color?: C
defaultValue?: string
design?: D
disabled?: boolean
icon: ANY
label: string
name: string
noEmpty?: boolean
onChange?: ANY
options: string[] | number[] | { label: string; value: string | number }[]
placeholder?: string
required?: boolean
size: S
}
// designs / color / size
export default function selectFactory<C extends string = never, D extends string = never, S extends string = never>({
styles,
}: Options<C, D, S>) {
if (Array.isArray(styles)) {
styles = mergeStyles(styles)
}
const Select: FunctionComponent<Props<C, D, S>> = ({
autoFocus,
className,
color,
defaultValue,
design,
disabled,
icon,
name,
label,
noEmpty,
onChange,
placeholder,
required,
size,
options,
}) => {
const [touched, setTouched] = useState(false)
const selectRef = useRef<HTMLSelectElement>(null)
const onBlur = useCallback(() => setTouched(true), [])
useEffect(() => {
const form = selectRef.current!.form
const resetTouched = setTouched.bind(null, false)
form!.addEventListener('reset', resetTouched)
return () => form!.removeEventListener('reset', resetTouched)
}, [])
useEffect(() => {
if (autoFocus) selectRef.current!.focus()
}, [autoFocus])
return (
<div
className={cn(
styles.base,
design && styles[design],
color && styles[color],
size && styles[size],
touched && styles.touched,
className,
)}
>
{icon && <div className={cn(styles.icon)} style={{ maskImage: icon }} />}
<select
autoFocus={autoFocus}
className={styles.element}
disabled={disabled}
name={name}
onBlur={onBlur}
onChange={onChange}
ref={selectRef}
defaultValue={defaultValue}
required={required}
>
{!noEmpty && <option value=''>{placeholder || label}</option>}
{options &&
options.map((option) => (
<option
key={typeof option === 'object' ? option.value : option}
value={typeof option === 'object' ? option.value : option}
>
{typeof option === 'object' ? option.label : option}
</option>
))}
</select>
{/**
* <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={styles.label} htmlFor={name}>
{label}
</label>
)}
</div>
)
}
return Select
}