main
ts 133 lines 5.22 KB
Raw
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 }