| 1 | // This file is part of HFS - Copyright 2021-2023, Massimo Melina <a@rejetto.com> - License https://www.gnu.org/licenses/gpl-3.0.txt |
| 2 | |
| 3 | import { createElement as h, Fragment, ReactNode, useEffect, useRef, useState } from 'react' |
| 4 | import { FieldProps } from '.' |
| 5 | import { Autocomplete, InputAdornment, TextField } from '@mui/material' |
| 6 | import { StandardTextFieldProps } from '@mui/material/TextField/TextField' |
| 7 | |
| 8 | export interface StringFieldProps extends FieldProps<string>, Partial<Omit<StandardTextFieldProps, 'label' | 'onChange' | 'value'>> { |
| 9 | typing?: boolean // change state as the user is typing |
| 10 | onTyping?: (v: string) => string | false |
| 11 | min?: number |
| 12 | max?: number |
| 13 | required?: boolean |
| 14 | start?: ReactNode |
| 15 | end?: ReactNode |
| 16 | wrap?: boolean |
| 17 | } |
| 18 | export function StringField({ |
| 19 | value, onChange, min, max, required, setApi, typing, start, end, onTyping, suggestions, wrap, fieldRef, |
| 20 | InputProps, InputLabelProps, inputProps, slotProps, ...props |
| 21 | }: StringFieldProps) { |
| 22 | const normalized = value ?? '' |
| 23 | setApi?.({ |
| 24 | getError() { |
| 25 | return !value && required ? "required" |
| 26 | : value?.length! < min! ? "too short" |
| 27 | : value?.length! > max! ? "too long" |
| 28 | : false |
| 29 | } |
| 30 | }) |
| 31 | const [state, setState] = useState(normalized) |
| 32 | |
| 33 | const lastChange = useRef(normalized) |
| 34 | useEffect(() => { |
| 35 | setState(normalized) |
| 36 | lastChange.current = normalized |
| 37 | }, [normalized]) |
| 38 | const valueFocusing = useRef<string | undefined>() |
| 39 | const autoFillDetected = useRef(false) |
| 40 | const render = (params: any) => { |
| 41 | // callers still use both legacy adornments and StringField's shortcuts, so keep them equivalent under slotProps |
| 42 | const inputSlotProps = { |
| 43 | ...slotProps?.input, |
| 44 | ...InputProps, |
| 45 | ...params?.slotProps?.input, |
| 46 | } |
| 47 | return h(TextField, { |
| 48 | fullWidth: true, |
| 49 | ...params, |
| 50 | ...props, |
| 51 | ref: fieldRef, |
| 52 | sx: props.label ? props.sx : Object.assign({ '& .MuiInputBase-input': { pt: 1.5 } }, props.sx), |
| 53 | value: state, |
| 54 | // v9 routes TextField customization through slotProps; keep the old callers working by folding the legacy props in here |
| 55 | slotProps: { |
| 56 | ...slotProps, |
| 57 | input: { |
| 58 | ...inputSlotProps, |
| 59 | startAdornment: addShortcutAdornment('start', start, inputSlotProps.startAdornment), |
| 60 | endAdornment: addShortcutAdornment('end', end, inputSlotProps.endAdornment), |
| 61 | }, |
| 62 | inputLabel: state || props.placeholder || InputLabelProps || slotProps?.inputLabel ? { |
| 63 | shrink: true, |
| 64 | ...params?.slotProps?.inputLabel, |
| 65 | ...InputLabelProps, |
| 66 | ...slotProps?.inputLabel, |
| 67 | } : undefined, |
| 68 | htmlInput: { |
| 69 | ...slotProps?.htmlInput, |
| 70 | ...inputProps, |
| 71 | ...params?.slotProps?.htmlInput, |
| 72 | }, |
| 73 | }, |
| 74 | onChange(ev) { |
| 75 | let val = ev.target.value |
| 76 | if (wrap && val.includes('\n')) return // prevent newlines, we are a wrapped yet single line |
| 77 | if (onTyping) { |
| 78 | const res = onTyping(val) |
| 79 | if (res === false) return |
| 80 | val = res |
| 81 | } |
| 82 | setState(val) |
| 83 | if (typing || autoFillDetected.current || valueFocusing.current === undefined) |
| 84 | go(ev, val) |
| 85 | }, |
| 86 | onKeyDown(ev) { |
| 87 | props.onKeyDown?.(ev) |
| 88 | autoFillDetected.current = ev.code === undefined |
| 89 | if (ev.key === 'Enter' && (ev.target as HTMLElement).ariaExpanded !== 'true') // don't act if suggestion list is expanded |
| 90 | go(ev) |
| 91 | }, |
| 92 | onFocus(ev) { |
| 93 | valueFocusing.current = ev.target.value |
| 94 | }, |
| 95 | onBlur(ev) { |
| 96 | props.onBlur?.(ev) |
| 97 | if (valueFocusing.current !== ev.target.value) |
| 98 | go(ev) |
| 99 | }, |
| 100 | }) |
| 101 | } |
| 102 | return !suggestions ? render(null) |
| 103 | : h(Autocomplete, { |
| 104 | freeSolo: true, |
| 105 | options: suggestions, |
| 106 | renderInput: render, |
| 107 | onChange(ev, v) { |
| 108 | go(ev, v as string) |
| 109 | } |
| 110 | }) |
| 111 | |
| 112 | function addShortcutAdornment(position: 'start' | 'end', shortcut: ReactNode, existing: ReactNode) { |
| 113 | if (!shortcut) |
| 114 | return existing |
| 115 | const shortcutAdornment = h(InputAdornment, { position }, shortcut) |
| 116 | return position === 'start' |
| 117 | ? h(Fragment, {}, shortcutAdornment, existing) |
| 118 | : h(Fragment, {}, existing, shortcutAdornment) |
| 119 | } |
| 120 | |
| 121 | function go(event: any, newVal: string=state) { |
| 122 | newVal = newVal?.trim() |
| 123 | if (newVal === lastChange.current) return // don't compare to 'value' as that represents only accepted changes, while we are interested also in changes through discarded values |
| 124 | lastChange.current = newVal |
| 125 | onChange(newVal, { |
| 126 | was: value, |
| 127 | event, |
| 128 | cancel() { |
| 129 | setState(normalized) |
| 130 | } |
| 131 | }) |
| 132 | } |
| 133 | } |