1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-01-06 00:07:44 +01:00
unleash.unleash/frontend/src/component/project/Project/CreateProject/SelectionButton.tsx
Thomas Heartman be4bb86b92
fix: add accessible descriptions to the dropdowns (#7112)
This PR adds accessible descriptions to the dropdown widgets in the new
project creation form. The description is the same as we show in the
background
2024-05-22 14:02:05 +02:00

446 lines
14 KiB
TypeScript

import Search from '@mui/icons-material/Search';
import { v4 as uuidv4 } from 'uuid';
import { Box, Button, InputAdornment, List, ListItemText } from '@mui/material';
import { type FC, type ReactNode, useRef, useState, useMemo } from 'react';
import {
StyledCheckbox,
StyledDropdown,
StyledListItem,
StyledPopover,
StyledDropdownSearch,
TableSearchInput,
HiddenDescription,
} from './SelectionButton.styles';
import { ChangeRequestTable } from './ChangeRequestTable';
export interface IFilterItemProps {
label: ReactNode;
options: Array<{ label: string; value: string }>;
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
}
export type FilterItemParams = {
operator: string;
values: string[];
};
interface UseSelectionManagementProps {
handleToggle: (value: string) => () => void;
}
const useSelectionManagement = ({
handleToggle,
}: UseSelectionManagementProps) => {
const listRefs = useRef<Array<HTMLInputElement | HTMLLIElement | null>>([]);
const handleSelection = (
event: React.KeyboardEvent,
index: number,
filteredOptions: { label: string; value: string }[],
) => {
// we have to be careful not to prevent other keys e.g tab
if (event.key === 'ArrowDown' && index < listRefs.current.length - 1) {
event.preventDefault();
listRefs.current[index + 1]?.focus();
} else if (event.key === 'ArrowUp' && index > 0) {
event.preventDefault();
listRefs.current[index - 1]?.focus();
} else if (
event.key === 'Enter' &&
index === 0 &&
listRefs.current[0]?.value &&
filteredOptions.length > 0
) {
// if the search field is not empty and the user presses
// enter from the search field, toggle the topmost item in
// the filtered list event.preventDefault();
handleToggle(filteredOptions[0].value)();
} else if (
event.key === 'Enter' ||
// allow selection with space when not in the search field
(index !== 0 && event.key === ' ')
) {
event.preventDefault();
if (index > 0) {
const listItemIndex = index - 1;
handleToggle(filteredOptions[listItemIndex].value)();
}
}
};
return { listRefs, handleSelection };
};
type CombinedSelectProps = {
options: Array<{ label: string; value: string }>;
onChange: (value: string) => void;
button: { label: string; icon: ReactNode };
search: {
label: string;
placeholder: string;
};
multiselect?: { selectedOptions: Set<string> };
onOpen?: () => void;
onClose?: () => void;
description: string; // visually hidden, for assistive tech
};
const CombinedSelect: FC<CombinedSelectProps> = ({
options,
onChange,
button,
search,
multiselect,
onOpen = () => {},
onClose = () => {},
description,
}) => {
const ref = useRef<HTMLDivElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [searchText, setSearchText] = useState('');
const descriptionId = uuidv4();
const [recentlyClosed, setRecentlyClosed] = useState(false);
const open = () => {
setSearchText('');
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
const onSelection = (selected: string) => {
onChange(selected);
if (!multiselect) {
handleClose();
setRecentlyClosed(true);
// this is a hack to prevent the button from being
// auto-clicked after you select an item by pressing enter
// in the search bar for single-select lists.
setTimeout(() => setRecentlyClosed(false), 1);
}
};
const { listRefs, handleSelection } = useSelectionManagement({
handleToggle: (selected: string) => () => onSelection(selected),
});
const filteredOptions = options?.filter((option) =>
option.label.toLowerCase().includes(searchText.toLowerCase()),
);
return (
<>
<Box ref={ref}>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
if (!recentlyClosed) {
open();
}
}}
>
{button.label}
</Button>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<HiddenDescription id={descriptionId}>
{description}
</HiddenDescription>
<StyledDropdown aria-describedby={descriptionId}>
<StyledDropdownSearch
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
label={search.label}
hideLabel
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
inputRef={(el) => {
listRefs.current[0] = el;
}}
onKeyDown={(event) =>
handleSelection(event, 0, filteredOptions)
}
/>
<List sx={{ overflowY: 'auto' }} disablePadding>
{filteredOptions.map((option, index) => {
const labelId = `checkbox-list-label-${option.value}`;
return (
<StyledListItem
aria-describedby={labelId}
key={option.value}
dense
disablePadding
tabIndex={0}
onClick={() => {
onSelection(option.value);
}}
ref={(el) => {
listRefs.current[index + 1] = el;
}}
onKeyDown={(event) =>
handleSelection(
event,
index + 1,
filteredOptions,
)
}
>
{multiselect ? (
<StyledCheckbox
edge='start'
checked={multiselect.selectedOptions.has(
option.value,
)}
tabIndex={-1}
inputProps={{
'aria-labelledby': labelId,
}}
size='small'
disableRipple
/>
) : null}
<ListItemText
id={labelId}
primary={option.label}
/>
</StyledListItem>
);
})}
</List>
</StyledDropdown>
</StyledPopover>
</>
);
};
type MultiselectListProps = Pick<
CombinedSelectProps,
'options' | 'button' | 'search' | 'onOpen' | 'onClose' | 'description'
> & {
selectedOptions: Set<string>;
onChange: (values: Set<string>) => void;
};
export const MultiselectList: FC<MultiselectListProps> = ({
selectedOptions,
onChange,
...rest
}) => {
// todo: add "select all" and "deselect all"
const handleToggle = (value: string) => {
if (selectedOptions.has(value)) {
selectedOptions.delete(value);
} else {
selectedOptions.add(value);
}
onChange(new Set(selectedOptions));
};
return (
<CombinedSelect
{...rest}
onChange={handleToggle}
multiselect={{
selectedOptions,
}}
/>
);
};
type SingleSelectListProps = Pick<
CombinedSelectProps,
| 'options'
| 'button'
| 'search'
| 'onChange'
| 'onOpen'
| 'onClose'
| 'description'
>;
export const SingleSelectList: FC<SingleSelectListProps> = (props) => {
return <CombinedSelect {...props} />;
};
type TableSelectProps = Pick<
CombinedSelectProps,
'button' | 'search' | 'onOpen' | 'onClose' | 'description'
> & {
updateProjectChangeRequestConfiguration: {
disableChangeRequests: (env: string) => void;
enableChangeRequests: (env: string, requiredApprovals: number) => void;
};
activeEnvironments: {
name: string;
type: string;
}[];
projectChangeRequestConfiguration: Record<
string,
{ requiredApprovals: number }
>;
disabled: boolean;
};
export const TableSelect: FC<TableSelectProps> = ({
button,
disabled,
search,
projectChangeRequestConfiguration,
updateProjectChangeRequestConfiguration,
activeEnvironments,
onOpen = () => {},
onClose = () => {},
}) => {
const configured = useMemo(() => {
return Object.fromEntries(
Object.entries(projectChangeRequestConfiguration).map(
([name, config]) => [
name,
{ ...config, changeRequestEnabled: true },
],
),
);
}, [projectChangeRequestConfiguration]);
const tableEnvs = useMemo(
() =>
activeEnvironments.map(({ name, type }) => ({
name,
type,
...(configured[name] ?? { changeRequestEnabled: false }),
})),
[configured, activeEnvironments],
);
const onEnable = (name: string, requiredApprovals: number) => {
updateProjectChangeRequestConfiguration.enableChangeRequests(
name,
requiredApprovals,
);
};
const onDisable = (name: string) => {
updateProjectChangeRequestConfiguration.disableChangeRequests(name);
};
const ref = useRef<HTMLDivElement>(null);
const [anchorEl, setAnchorEl] = useState<HTMLDivElement | null>();
const [searchText, setSearchText] = useState('');
const open = () => {
setSearchText('');
setAnchorEl(ref.current);
onOpen();
};
const handleClose = () => {
setAnchorEl(null);
onClose();
};
const filteredEnvs = tableEnvs.filter((env) =>
env.name.toLowerCase().includes(searchText.toLowerCase()),
);
const toggleTopItem = (event: React.KeyboardEvent) => {
if (
event.key === 'Enter' &&
searchText.trim().length > 0 &&
filteredEnvs.length > 0
) {
const firstEnv = filteredEnvs[0];
if (firstEnv.name in configured) {
onDisable(firstEnv.name);
} else {
onEnable(firstEnv.name, 1);
}
}
};
return (
<>
<Box ref={ref}>
<Button
variant='outlined'
color='primary'
startIcon={button.icon}
onClick={() => {
open();
}}
disabled={disabled}
>
{button.label}
</Button>
</Box>
<StyledPopover
open={Boolean(anchorEl)}
anchorEl={anchorEl}
onClose={handleClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'left',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'left',
}}
>
<StyledDropdown>
<TableSearchInput
variant='outlined'
size='small'
value={searchText}
onChange={(event) => setSearchText(event.target.value)}
hideLabel
label={search.label}
placeholder={search.placeholder}
autoFocus
InputProps={{
startAdornment: (
<InputAdornment position='start'>
<Search fontSize='small' />
</InputAdornment>
),
}}
onKeyDown={toggleTopItem}
/>
<ChangeRequestTable
environments={filteredEnvs}
enableEnvironment={onEnable}
disableEnvironment={onDisable}
/>
</StyledDropdown>
</StyledPopover>
</>
);
};