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