main
ts 165 lines 8.12 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, useId, useMemo, useCallback } from 'react'
4 import { FieldProps } from '.'
5 import {
6 FormControl, FormControlLabel, FormLabel, MenuItem, Radio, InputLabel, Select, LinearProgress,
7 ListItemText, Checkbox, FilledInput, RadioGroup, TextField, FormHelperText, Button, Box
8 } from '@mui/material'
9 import { SxProps } from '@mui/system'
10 import { useIsMobile } from '@hfs/shared'
11
12 type SelectOptions<T> = { [label:string]: T } | SelectOption<T>[]
13 type SelectOption<T> = SelectOptionNormalized<T> | (T extends string | number ? T : never)
14 interface SelectOptionNormalized<T> { label?: string, value: T, disabled?: boolean }
15 type RenderOption<T> = (option: SelectOptionNormalized<T>) => ReactNode
16
17 export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T> & { defaultValue?: T, renderOption?: RenderOption<T> }) {
18 let { defaultValue, value=defaultValue, onChange, setApi, options, sx, disabled, afterList, renderOption, ...rest } = props
19 const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
20 const jsonValue = JSON.stringify(value)
21 const currentOption = normalizedOptions?.find(x => JSON.stringify(x.value) === jsonValue)
22 renderOption ??= x => x.label ?? String(x.value)
23 return h(TextField, { // using TextField because Select is not displaying label correctly
24 select: true,
25 hiddenLabel: !props.label,
26 // avoid warning for invalid option. This can easily happen for a split-second when you keep value in a useState (or other async way) and calculate options with a useMemo (or other sync way) causing a temporary misalignment.
27 value: currentOption ? jsonValue : '',
28 disabled: normalizedOptions?.length === 0 || disabled,
29 children: !normalizedOptions ? h(LinearProgress) : [
30 ...normalizedOptions.map((o, i) => h(MenuItem, {
31 key: i,
32 value: JSON.stringify(o?.value),
33 disabled: o?.disabled,
34 children: h(Fragment, { key: i }, renderOption!(o)) // without this fragment/key, a label as h(span) will produce warnings
35 })),
36 h('div', { key: -1 }, afterList),
37 ],
38 ...commonSelectProps(props),
39 ...rest,
40 onChange(event) {
41 try {
42 let newVal: any = event.target.value
43 newVal = JSON.parse(newVal) as T
44 onChange(newVal, { was: value, event })
45 }
46 catch {}
47 }
48 })
49 }
50
51 type MultiSelectFieldProps<T> = FieldProps<T[]> & CommonSelectProps<T> & {
52 renderOption?: RenderOption<T>
53 clearable?: boolean
54 }
55 export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectFieldProps<T>) {
56 const { value, onChange, setApi, options, placeholder, helperText, label, valueSeparator = ', ', clearable=true, afterList, ...rest } = props
57 const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
58 const valueAsOptions = useMemo(() => !Array.isArray(value) ? []
59 : value.map(x => normalizedOptions?.find(o => o.value === x) || { value: x, label: String(x) }),
60 [value, normalizedOptions])
61 const valueAsJsons = useMemo(() => value?.map(x => JSON.stringify(x)) || [], [value])
62 const isMobile = useIsMobile()
63 const labelId = useId()
64 const helperId = useId()
65 const isEmpty = !valueAsOptions.length
66 renderOption ??= x => x.label ?? String(x.value)
67 return h(FormControl, { fullWidth: true, variant: 'filled', hiddenLabel: !label },
68 h(InputLabel, {
69 id: labelId,
70 sx: { '&.Mui-focused': { color: 'inherit' } }, // override style rule giving this a dim contrast and hard to read
71 }, label),
72 h(Select<string[]>, {
73 ...commonSelectProps(props),
74 multiple: true,
75 displayEmpty: true,
76 value: valueAsJsons,
77 onChange: event => {
78 let { value: v } = event.target
79 if (!Array.isArray(v)) { debugger; return }
80 v = v.map(x => x && JSON.parse(x)) // x can be undefined because of the clear-button
81 onChange(v as any, { was: value, event })
82 },
83 sx: { '& .MuiSelect-select': { maxHeight: '15em', overflowY: 'auto' } },
84 input: h(FilledInput, {
85 hiddenLabel: !label,
86 'aria-describedby': helperId,
87 }),
88 renderValue: () => h('div', {
89 'aria-label': label + ': ' + valueAsOptions.map(x => x.label ?? String(x.value)),
90 style: { overflow: "hidden", display: "flex", flexWrap: "wrap", gap: ".5em" },
91 children: isEmpty ? h(Box, { sx: { position: 'relative', top: '.3em', fontSize: 'small', fontStyle: 'italic', color: 'text.secondary' } }, placeholder)
92 : valueAsOptions.map((x, i) => h('span', { key: i }, renderOption!(x), i < valueAsOptions.length - 1 && valueSeparator)),
93 }),
94 ...rest,
95 },
96 !normalizedOptions ? h(LinearProgress)
97 : (!normalizedOptions.length && h(ListItemText, {
98 sx: { fontStyle: 'italic', ml: 1 },
99 onClickCapture(ev) { ev.stopPropagation() }
100 }, "No options available")),
101 !isMobile && normalizedOptions?.length! > 20 && h(Box, {
102 sx: { float: 'right', fontSize: 'small', width: '8em', textAlign: 'right', marginRight: '.5em' },
103 }, "ⓘ You can type the name"),
104 h(Button, {
105 size: 'small',
106 sx: { ml: 1, display: normalizedOptions?.length! > 1 ? undefined : 'none' },
107 ref: useCallback((x: HTMLButtonElement | null) =>
108 x && Object.assign(x, { role: undefined }) // cancel the role=option on this
109 && setTimeout(() => x.focus()),
110 []),
111 onClickCapture(event) {
112 event.stopPropagation()
113 onChange(isEmpty ? normalizedOptions!.map(x => x.value) : [], { was: value, event })
114 },
115 }, isEmpty ? "Select all" : "Unselect all"),
116 ...normalizedOptions?.map(o => h(MenuItem, { value: JSON.stringify(o?.value) }, // encode, as this supports only string|number
117 h(Checkbox, { checked: value?.includes(o.value) || false }),
118 h(ListItemText, { primary: renderOption!(o) })
119 )) || [],
120 afterList,
121 ),
122 h(FormHelperText, { id: helperId, error: props.error }, helperText),
123 )
124 }
125
126 type HelperCommon<T> = Pick<FieldProps<T>, 'value' | 'onChange' | 'label'>
127 interface CommonSelectProps<T> extends HelperCommon<T> {
128 sx?: SxProps
129 disabled?: boolean
130 // pass options undefined to display a loading indicator in place of the options
131 options?: SelectOptions<T>
132 afterList?: ReactNode
133 }
134 function commonSelectProps<T>(props: CommonSelectProps<T>) {
135 return {
136 fullWidth: true,
137 sx: Object.assign({
138 '& .MuiInputBase-inputHiddenLabel': {
139 py: 1,
140 '& .MuiInputAdornment-root': { ml: -1 },
141 }
142 }, props.sx),
143 }
144 }
145
146 function normalizeOptions<T>(options?: SelectOptions<T>) {
147 return !options ? undefined : !Array.isArray(options) ? Object.entries(options).map(([label,value]) => ({ value, label } as SelectOptionNormalized<T>))
148 : options.map(o => typeof o === 'string' || typeof o === 'number' ? { value: o } : o as SelectOptionNormalized<T>)
149 }
150
151 export function RadioField<T>({ label, options, value, onChange }: FieldProps<T> & { options:SelectOptionNormalized<T>[] }) {
152 return h(FormControl, {},
153 label && h(FormLabel, {}, label),
154 h(RadioGroup, {
155 row: true,
156 name: '',
157 value: JSON.stringify(value),
158 onChange(event, v) {
159 onChange(JSON.parse(v), { was: value, event })
160 },
161 children: options.map(({ value, label }, idx) =>
162 h(FormControlLabel, { key: idx, value, control: h(Radio), label }))
163 })
164 )
165 }