| 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 | } |