Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/manager/.changeset/pr-12583-fixed-1753471654074.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Fixed
---

UI and accessibility of disabled Autocomplete options ([#12583](https://github.com/linode/manager/pull/12583))
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe('ImageOption', () => {
);
expect(
getByText(image.label).closest('li')?.getAttribute('aria-label')
).toBe('');
).toBeNull();
});

it('renders (deprecated) if the image is deprecated', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,7 @@ export const ImageSelect = (props: Props) => {
rest.disableClearable ??
(selectIfOnlyOneOption && options.length === 1 && !multiple)
}
disabledItemsFocusable
errorText={rest.errorText ?? error?.[0].reason}
getOptionDisabled={(option) => Boolean(disabledImages[option.id])}
multiple={multiple}
Expand Down
3 changes: 1 addition & 2 deletions packages/manager/src/components/ImageSelect/utilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,7 @@ export const getDisabledImages = (options: DisabledImageOptions) => {
for (const image of images) {
if (!image.capabilities.includes('distributed-sites')) {
disabledImages[image.id] = {
reason:
'The selected image cannot be deployed to a distributed region.',
reason: 'This image cannot be deployed to a distributed region.',
};
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,11 @@ export const PlacementGroupsSelect = (props: PlacementGroupsSelectProps) => {
clearOnBlur={true}
data-testid="placement-groups-select"
disabled={Boolean(!selectedRegion?.id) || disabled}
disabledItemsFocusable
errorText={error?.[0]?.reason}
getOptionDisabled={(placementGroup) =>
isDisabledPlacementGroup(placementGroup, selectedRegion)
}
getOptionLabel={(placementGroup: PlacementGroup) => placementGroup.label}
helperText={
!selectedRegion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,11 @@ export const RegionSelect = <
return (
<StyledAutocompleteContainer sx={{ width }}>
<Autocomplete<Region, false, DisableClearable>
autoHighlight
clearOnBlur
data-testid="region-select"
disableClearable={disableClearable}
disabled={disabled}
disabledItemsFocusable
errorText={errorText}
filterOptions={filterOptions}
getOptionDisabled={(option) => Boolean(disabledRegions[option.id])}
Expand All @@ -126,7 +126,7 @@ export const RegionSelect = <
onChange={onChange}
options={regionOptions}
placeholder={placeholder ?? 'Select a Region'}
renderOption={(props, region, { selected }) => {
renderOption={(props, region, state) => {
const { key, ...rest } = props;

return (
Expand All @@ -136,7 +136,7 @@ export const RegionSelect = <
item={region}
key={`${region.id}-${key}`}
props={rest}
selected={selected}
selected={state.selected}
/>
);
}}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Autocomplete, ListItem, Tooltip, Typography } from '@linode/ui';
import { Autocomplete, ListItemOption, Typography } from '@linode/ui';
import * as React from 'react';

import { Link } from 'src/components/Link';
Expand All @@ -23,57 +23,38 @@ export const PlacementGroupTypeSelect = (props: Props) => {
)}
disableClearable={true}
disabled={disabledPlacementGroupCreateButton}
disabledItemsFocusable
errorText={error}
getOptionDisabled={(option) => option.value === 'affinity:local'}
label="Placement Group Type"
onChange={(_, value) => {
setFieldValue('placement_group_type', value?.value ?? '');
}}
options={placementGroupTypeOptions}
placeholder="Select an Placement Group Type"
renderOption={(props, option) => {
renderOption={(props, option, { selected }) => {
const { key, ...rest } = props;
const isDisabledMenuItem = option.value === 'affinity:local';
const isDisabled = option.value === 'affinity:local';

const disabledReason = (
<Typography>
Currently, only Anti-affinity placement groups are supported.{' '}
<Link to={PLACEMENT_GROUPS_DOCS_LINK}>Learn more</Link>.
</Typography>
);

return (
<Tooltip
data-qa-tooltip={isDisabledMenuItem ? 'antiAffinityHelperText' : ''}
disableFocusListener={!isDisabledMenuItem}
disableHoverListener={!isDisabledMenuItem}
disableTouchListener={!isDisabledMenuItem}
enterDelay={200}
enterNextDelay={200}
enterTouchDelay={200}
key={key}
title={
isDisabledMenuItem ? (
<Typography>
Currently, only Anti-affinity placement groups are supported.{' '}
<Link to={PLACEMENT_GROUPS_DOCS_LINK}>Learn more</Link>.
</Typography>
) : (
''
)
<ListItemOption
disabledOptions={
isDisabled ? { reason: disabledReason } : undefined
}
item={{ ...option, id: option.value }}
key={key}
props={rest}
selected={selected}
>
<ListItem
{...rest}
className={
isDisabledMenuItem
? `${props.className} Mui-disabled`
: props.className
}
component="li"
onClick={(e) =>
isDisabledMenuItem
? e.preventDefault()
: props.onClick
? props.onClick(e)
: null
}
>
{option.label}
</ListItem>
</Tooltip>
{option.label}
</ListItemOption>
);
}}
textFieldProps={{
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { fireEvent, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';

import { placementGroupFactory } from 'src/factories';
Expand Down Expand Up @@ -113,29 +114,35 @@ describe('PlacementGroupsCreateDrawer', () => {
});

it('should display an error message if the region has reached capacity', async () => {
/**
* Note: this unit test assumes regions are mocked from the MSW's serverHandles.ts
* and that us-west has special limits
*/
queryMocks.useAllPlacementGroupsQuery.mockReturnValue({
data: [placementGroupFactory.build({ region: 'us-west' })],
});
const regionWithoutCapacity = 'US, Fremont, CA (us-west)';
const { getByPlaceholderText, getByText } = renderWithTheme(

const { findByText, getByPlaceholderText, getByRole } = renderWithTheme(
<PlacementGroupsCreateDrawer {...commonProps} />
);

const regionSelect = getByPlaceholderText('Select a Region');
fireEvent.focus(regionSelect);
fireEvent.change(regionSelect, {
target: { value: regionWithoutCapacity },
});
await waitFor(() => {
expect(getByText(regionWithoutCapacity)).toBeInTheDocument();
});

await userEvent.click(regionSelect);

const regionWithNoCapacityOption = await findByText(regionWithoutCapacity);

await userEvent.click(regionWithNoCapacityOption);

const tooltip = getByRole('tooltip');

await waitFor(() => {
expect(
getByText(
'You’ve reached the limit of placement groups you can create in this region.'
)
).toBeInTheDocument();
expect(tooltip.textContent).toContain(
'You’ve reached the limit of placement groups you can create in this region.'
);
});

expect(tooltip).toBeVisible();
});
});
154 changes: 83 additions & 71 deletions packages/ui/src/components/ListItemOption/ListItemOption.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { styled } from '@mui/material/styles';
import { visuallyHidden } from '@mui/utils';
import React, { type JSX } from 'react';
import type { JSX } from 'react';
import React, { useEffect, useRef, useState } from 'react';

import { SelectedIcon } from '../Autocomplete';
import { Box } from '../Box';
Expand Down Expand Up @@ -39,78 +39,90 @@ export const ListItemOption = <T,>({
props,
selected,
}: ListItemOptionProps<T>) => {
const { className, onClick, ...rest } = props;
const isItemOptionDisabled = Boolean(disabledOptions);
const itemOptionDisabledReason = disabledOptions?.reason;
const { onClick, ...rest } = props;
const isOptionDisabled = Boolean(disabledOptions);
const disabledReason = disabledOptions?.reason;

return (
<Tooltip
disableFocusListener={!isItemOptionDisabled}
disableHoverListener={!isItemOptionDisabled}
disableTouchListener={!isItemOptionDisabled}
enterDelay={200}
enterNextDelay={200}
enterTouchDelay={200}
PopperProps={{
sx: {
'& .MuiTooltip-tooltip': {
minWidth: disabledOptions?.tooltipWidth ?? 215,
},
},
}}
title={
isItemOptionDisabled && itemOptionDisabledReason
? itemOptionDisabledReason
: ''
// Used to control the Tooltip
const [isFocused, setIsFocused] = useState(false);
const listItemRef = useRef<HTMLLIElement>(null);

useEffect(() => {
if (!listItemRef.current) {
// Ensure ref is established
return;
}
if (!isOptionDisabled) {
// We don't need to setup the mutation observer for options that are enabled. They won't have a tooltip
return;
}

const observer = new MutationObserver(() => {
const className = listItemRef.current?.className;
const hasFocusedClass = className?.includes('Mui-focused') ?? false;
if (hasFocusedClass) {
setIsFocused(true);
} else if (!hasFocusedClass) {
setIsFocused(false);
}
});

observer.observe(listItemRef.current, { attributeFilter: ['class'] });

return () => {
observer.disconnect();
};
}, [isOptionDisabled]);

const Option = (
<ListItem
{...rest}
data-qa-disabled-item={isOptionDisabled}
onClick={(e) =>
isOptionDisabled ? e.preventDefault() : onClick ? onClick(e) : null
}
ref={listItemRef}
slotProps={{
root: {
'data-qa-option': item.id,
'data-testid': item.id,
} as ListItemComponentsPropsOverrides,
}}
sx={{
display: 'flex',
justifyContent: 'space-between',
maxHeight,
gap: 1,
...(isOptionDisabled && {
cursor: 'not-allowed !important',
pointerEvents: 'unset !important' as 'unset',
}),
}}
>
<StyledDisabledItem
{...rest}
aria-disabled={undefined}
className={
isItemOptionDisabled ? `${className} Mui-disabled` : className
}
componentsProps={{
root: {
'data-qa-option': item.id,
'data-testid': item.id,
} as ListItemComponentsPropsOverrides,
}}
data-qa-disabled-item={isItemOptionDisabled}
onClick={(e) =>
isItemOptionDisabled
? e.preventDefault()
: onClick
? onClick(e)
: null
}
style={{
display: 'flex',
justifyContent: 'space-between',
maxHeight,
{children}
{isOptionDisabled && <Box sx={visuallyHidden}>{disabledReason}</Box>}
<Box flexGrow={1} />
{selected && <SelectedIcon visible />}
</ListItem>
);

if (isOptionDisabled) {
return (
<Tooltip
open={isFocused}
slotProps={{
tooltip: {
sx: {
minWidth: disabledOptions?.tooltipWidth ?? 215,
},
},
}}
title={disabledReason}
>
{children}
{isItemOptionDisabled && (
<Box sx={visuallyHidden}>{itemOptionDisabledReason}</Box>
)}
{selected && <SelectedIcon style={{ marginLeft: 8 }} visible />}
</StyledDisabledItem>
</Tooltip>
);
};
{Option}
</Tooltip>
);
}

export const StyledDisabledItem = styled(ListItem, {
label: 'StyledDisabledItem',
})(() => ({
'&.Mui-disabled': {
cursor: 'not-allowed',
},
'&.MuiAutocomplete-option': {
minHeight: 'auto !important',
padding: '8px 10px !important',
},
'&.MuiListItem-root[aria-disabled="true"]:active': {
pointerEvents: 'none !important',
},
}));
return Option;
};