a11y: new field for multi-select options (countries and permission-accounts)

Massimo Melina committed Jan 19, 2024 at 12:07 UTC 766800d8399a11fbd83fd13db6e3cfdf567af858
3 files changed +81 -71
admin/src/InternetPage.ts
+1
@@ -127,6 +127,7 @@ export default function InternetPage() {
127 k: CFG.geo_list,
128 comp: MultiSelectField<string>,
129 label: `Selected countries (${values[CFG.geo_list]?.length || 0})`,
130 + valueSeparator: false,
131 placeholder: "none",
132 options: countryOptions,
133 renderOption: (v: any) => h(Country, { code: v.value, long: true }),
admin/src/mui.ts
+6 -1
@@ -249,7 +249,12 @@ export function Country({ code, ip, def, long, short }: { code: string, ip?: str
249 return !country ? h(Fragment, {}, def) : h(Tooltip, {
250 title: long ? undefined : country.name,
251 children: h('span', {},
252 - h('img', { className: 'flag icon-w-text', src: `flags/${code.toLowerCase()}.png`, alt: country.name }),
252 + h('img', {
253 + className: 'flag icon-w-text',
254 + src: `flags/${code.toLowerCase()}.png`,
255 + alt: country.name,
256 + ...long && { 'aria-hidden': true },
257 + }),
258 long ? country.name + prefix(' (', short && code, ')') : code
259 )
260 })
mui-grid-form/SelectField.ts
+74 -70
@@ -1,21 +1,31 @@
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, useMemo } from 'react'
3 +import { createElement as h, Fragment, ReactNode, useId, useMemo } from 'react'
4 import { FieldProps } from '.'
5 -import {
6 - Autocomplete, Chip, FormControl, FormControlLabel, FormLabel, IconButton, InputAdornment, MenuItem, Radio,
7 - RadioGroup, StandardTextFieldProps, TextField, Tooltip
8 -} from '@mui/material'
5 +import { FormControl, FormControlLabel, FormLabel, MenuItem, Radio, InputLabel, Select,
6 + ListItemText, Checkbox, FilledInput, RadioGroup, TextField, FormHelperText, Button } from '@mui/material'
7 import { SxProps } from '@mui/system'
10 -import { Clear } from '@mui/icons-material'
8
9 type SelectOptions<T> = { [label:string]: T } | SelectOption<T>[]
10 type SelectOption<T> = SelectPair<T> | (T extends string | number ? T : never)
11 interface SelectPair<T> { label: string, value: T }
12
13 export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
17 - const { value, onChange, setApi, options, sx, ...rest } = props
14 + const { value, onChange, setApi, options, sx, disabled, ...rest } = props
15 + const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
16 + const jsonValue = JSON.stringify(value)
17 + const currentOption = normalizedOptions.find(x => JSON.stringify(x.value) === jsonValue)
18 return h(TextField, { // using TextField because Select is not displaying label correctly
19 + select: true,
20 + hiddenLabel: !props.label,
21 + // 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.
22 + value: currentOption ? jsonValue : '',
23 + disabled: !normalizedOptions?.length || disabled,
24 + children: normalizedOptions.map((o, i) => h(MenuItem, {
25 + key: i,
26 + value: JSON.stringify(o?.value),
27 + children: h(Fragment, { key: i }, o?.label) // without this fragment/key, a label as h(span) will produce warnings
28 + })),
29 ...commonSelectProps(props),
30 ...rest,
31 onChange(event) {
@@ -29,84 +39,78 @@ export function SelectField<T>(props: FieldProps<T> & CommonSelectProps<T>) {
39 })
40 }
41
32 -export function MultiSelectField<T>({ renderOption, ...props }: FieldProps<T[]> & CommonSelectProps<T> & { renderOption?: (option: SelectPair<T>) => ReactNode }) {
33 - const { value, onChange, setApi, options, sx, clearable, clearValue, placeholder, autocompleteProps, ...rest } = props
34 - const { select, InputProps, ...common } = commonSelectProps({ clearValue: [], ...props, clearable: false })
42 +type MultiSelectFieldProps<T> = FieldProps<T[]> & CommonSelectProps<T> & {
43 + renderOption?: (option: SelectPair<T>) => ReactNode
44 + clearable?: boolean
45 +}
46 +export function MultiSelectField<T>({ renderOption, ...props }: MultiSelectFieldProps<T>) {
47 + const { value, onChange, setApi, options, placeholder, helperText, label, valueSeparator = ', ', clearable=true, ...rest } = props
48 const normalizedOptions = useMemo(() => normalizeOptions(options), [options])
49 const valueAsOptions = useMemo(() => !Array.isArray(value) ? []
50 : value.map(x => normalizedOptions.find(o => o.value === x) || { value: x, label: String(x) }),
51 [value, normalizedOptions])
39 - return h(Autocomplete<SelectPair<T>, true>, {
40 - multiple: true,
41 - options: normalizedOptions,
42 - filterSelectedOptions: true,
43 - onChange: (event, sel) => onChange(sel.map(x => x.value) as T[], { was: value, event }),
44 - isOptionEqualToValue: (option, val) => option.value === val.value,
45 - getOptionLabel: x => x.label,
46 - renderOption: (props, x) => h('span', props, renderOption?.(x) ?? x.label),
47 - ...common,
48 - ...autocompleteProps,
49 - value: valueAsOptions,
50 - renderInput: params => h(TextField, {
52 + const valueAsJsons = useMemo(() => value?.map(x => JSON.stringify(x)) || [], [value])
53 + const labelId = useId()
54 + const helperId = useId()
55 + const showClear = valueAsOptions.length > 0
56 + return h(FormControl, { fullWidth: true, variant: 'filled', hiddenLabel: !label },
57 + h(InputLabel, {
58 + id: labelId,
59 + sx: { '&.Mui-focused': { color: 'inherit' } }, // override style rule giving this a dim contrast and hard to read
60 + }, label),
61 + h(Select<string[]>, {
62 + ...commonSelectProps(props),
63 + multiple: true,
64 + value: valueAsJsons,
65 + onChange: event => {
66 + let { value: v } = event.target
67 + if (!Array.isArray(v)) { debugger; return }
68 + v = v.map(x => x && JSON.parse(x)) // x can be undefined because of the clear-button
69 + onChange(v as any, { was: value, event })
70 + },
71 + input: h(FilledInput, {
72 + placeholder,
73 + hiddenLabel: !label,
74 + 'aria-describedby': helperId,
75 + }),
76 + renderValue: () => h('div', {
77 + 'aria-label': label + ': ' + valueAsOptions.map(x => x.label),
78 + style: { overflow: "hidden", display: "flex", flexWrap: "wrap", gap: ".5em" },
79 + children: valueAsOptions.map((x, i) => h('span', { key: i }, renderOption?.(x) ?? x.label, i < valueAsOptions.length - 1 && valueSeparator)),
80 + }),
81 ...rest,
52 - placeholder: valueAsOptions.length ? undefined : placeholder, // TextField's own logic doesn't know about the main field not being empty
53 - SelectProps: { multiple: true },
54 - sx: { ...rest.sx, '& div[role=button]': { whiteSpace: 'unset' } },
55 - ...params,
56 - }),
57 - renderTags: (tagValue, getTagProps) =>
58 - tagValue.map((option, index) =>
59 - h(Chip, { label: renderOption?.(option) ?? option.label, ...getTagProps({ index }) })),
60 - sx: {
61 - '.MuiAutocomplete-tag': { height: 24 }, // too tall, otherwise
62 - '.MuiAutocomplete-inputRoot': { pt: '21px' }, // some extra margin from label
63 - 'input[type][type]': { p: '4px' },
64 - '.MuiChip-deleteIcon[class]': { position: 'absolute', right: '-0.6em', opacity: 0, color: 'text.primary', transition: 'all .2s' },
65 - '.MuiChip-root:hover .MuiChip-deleteIcon': { opacity: 1 },
66 - ...sx,
67 - }
68 - })
82 + },
83 + h(Button, {
84 + ref: x => x && Object.assign(x, { role: undefined }), // cancel the role=option on this
85 + onClickCapture(event) {
86 + event.stopPropagation()
87 + onChange(showClear ? [] : normalizedOptions.map(x => x.value), { was: value, event })
88 + },
89 + }, showClear ? "Unselect all" : "Select all"),
90 + ...normalizedOptions.map(o => h(MenuItem, { value: JSON.stringify(o?.value) }, // encode, as this supports only string|number
91 + h(Checkbox, { checked: value?.includes(o.value) || false }),
92 + h(ListItemText, { primary: renderOption?.(o) ?? o.label })
93 + )),
94 + ),
95 + h(FormHelperText, { id: helperId, error: props.error }, helperText),
96 + )
97 }
98
71 -type HelperCommon<T> = Partial<Omit<StandardTextFieldProps, 'label' | 'value' | 'onChange'>> & Pick<FieldProps<T>, 'value' | 'onChange' | 'label'>
99 +type HelperCommon<T> = Pick<FieldProps<T>, 'value' | 'onChange' | 'label'>
100 interface CommonSelectProps<T> extends HelperCommon<T> {
101 sx?: SxProps
102 disabled?: boolean
75 - clearable?: boolean
76 - clearValue?: T | []
103 options: SelectOptions<T>
78 - start?: ReactNode
79 - end?: ReactNode
104 }
105 function commonSelectProps<T>(props: CommonSelectProps<T>) {
82 - const { options, disabled, start, end, clearable, clearValue, value } = props
83 - const normalizedOptions = normalizeOptions(options)
84 - const jsonValue = JSON.stringify(value)
85 - const currentOption = normalizedOptions.find(x => JSON.stringify(x.value) === jsonValue)
86 - const showClear = clearable && (Array.isArray(value) ? value.length > 0 : value)
106 return {
88 - select: true,
107 fullWidth: true,
90 - sx: props.label ? props.sx : Object.assign({ '& .MuiInputBase-input': { pt: 1 } }, props.sx),
91 - // 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.
92 - value: currentOption ? jsonValue : '',
93 - disabled: !normalizedOptions?.length || disabled,
94 - InputProps: {
95 - startAdornment: (start || showClear) && h(InputAdornment, { position: 'start' },
96 - showClear && h(Tooltip, { title: "Clear", children: h(IconButton, {
97 - onClick(event) {
98 - props.onChange(clearValue as any, { was: value, event })
99 - }
100 - }, h(Clear)) }),
101 - start),
102 - endAdornment: end && h(InputAdornment, { position: 'end' }, end),
103 - ...props.InputProps,
104 - },
105 - children: normalizedOptions.map((o, i) => h(MenuItem, {
106 - key: i,
107 - value: JSON.stringify(o?.value),
108 - children: h(Fragment, { key: i }, o?.label) // without this fragment/key, a label as h(span) will produce warnings
109 - }))
108 + sx: Object.assign({
109 + '& .MuiInputBase-inputHiddenLabel': {
110 + py: 1,
111 + '& .MuiInputAdornment-root': { ml: -1 },
112 + }
113 + }, props.sx),
114 }
115 }
116