Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(Select): Support disabling individual items #1493

Merged
merged 3 commits into from
Dec 16, 2024
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 .changeset/feat-select-disabled-items.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'react-magma-dom': minor
---

feat(Select): Support disabling individual items in Select and Multi Select components
34 changes: 24 additions & 10 deletions packages/react-magma-dom/src/components/Select/ItemsList.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,24 @@
import React from 'react';
import { ThemeContext } from '../../theme/ThemeContext';
import { I18nContext } from '../../i18n';
import { StyledCard, StyledItem, StyledList } from './shared';
import styled from '@emotion/styled';
import { ReferenceType } from '@floating-ui/react-dom';
import {
UseSelectGetItemPropsOptions,
UseSelectGetMenuPropsOptions,
} from 'downshift';
import { instanceOfToBeCreatedItemObject } from '.';
import React from 'react';
import {
instanceOfToBeCreatedItemObject,
} from '.';
import { I18nContext } from '../../i18n';
import { ThemeContext } from '../../theme/ThemeContext';
import { convertStyleValueToString } from '../../utils';
import { Spinner } from '../Spinner';
import {
defaultComponents,
ItemRenderOptions,
SelectComponents,
} from './components';
import { convertStyleValueToString } from '../../utils';
import { Spinner } from '../Spinner';
import styled from '@emotion/styled';
import { ReferenceType } from '@floating-ui/react-dom';
import { StyledCard, StyledItem, StyledList } from './shared';
import { isItemDisabled } from './utils';

interface ItemsListProps<T> {
customComponents?: SelectComponents<T>;
Expand All @@ -31,6 +34,7 @@ interface ItemsListProps<T> {
maxHeight?: number | string;
menuStyle?: React.CSSProperties;
setFloating?: (node: ReferenceType) => void;
setHighlightedIndex?: (index: number) => void;
}

const NoItemsMessage = styled.span<{
Expand Down Expand Up @@ -67,6 +71,7 @@ export function ItemsList<T>(props: ItemsListProps<T>) {
maxHeight,
menuStyle,
setFloating,
setHighlightedIndex,
} = props;

const theme = React.useContext(ThemeContext);
Expand Down Expand Up @@ -98,7 +103,7 @@ export function ItemsList<T>(props: ItemsListProps<T>) {
}

return (
<div ref={setFloating} style={{...floatingElementStyles, zIndex: '2'}}>
<div ref={setFloating} style={{ ...floatingElementStyles, zIndex: '2' }}>
<StyledCard
hasDropShadow
isInverse={isInverse}
Expand All @@ -117,10 +122,12 @@ export function ItemsList<T>(props: ItemsListProps<T>) {
const itemString = instanceOfToBeCreatedItemObject(item)
? item.label
: itemToString(item);
const isDisabled = isItemDisabled(item)

const { ref, ...otherDownshiftItemProps } = getItemProps({
item,
index,
disabled: isDisabled,
});

const key = `${itemString}${index}`;
Expand All @@ -133,9 +140,16 @@ export function ItemsList<T>(props: ItemsListProps<T>) {
itemString,
key,
theme,
isDisabled: isDisabled,
...otherDownshiftItemProps,
};

if (isDisabled) {
itemProps.onMouseEnter = () => {
setHighlightedIndex && setHighlightedIndex(-1);
};
}

return <Item<T> {...itemProps} key={key} />;
})
) : (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,25 @@ describe('Select', () => {
expect(getByText(helperMessage)).toBeInTheDocument();
});

it('should handle disabled items', () => {
const items = [
{ label: 'Red', value: 'red', disabled: true },
{ label: 'Blue', value: 'blue', disabled: false },
{ label: 'Green', value: 'green' },
];

const { getByLabelText, getByText } = render(
<MultiSelect labelText={labelText} items={items} />
);

const renderedSelect = getByLabelText(labelText, { selector: 'div' });
fireEvent.click(renderedSelect);

expect(getByText('Red')).toHaveAttribute('aria-disabled', 'true');
expect(getByText('Blue')).toHaveAttribute('aria-disabled', 'false');
expect(getByText('Green')).toHaveAttribute('aria-disabled', 'false');
});

describe('events', () => {
it('onBlur', () => {
const onBlur = jest.fn();
Expand Down
77 changes: 70 additions & 7 deletions packages/react-magma-dom/src/components/Select/MultiSelect.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { ThemeContext } from '../../theme/ThemeContext';
import { I18nContext } from '../../i18n';
import { ButtonSize, ButtonVariant } from '../Button';
import { defaultComponents } from './components';
import { useForkedRef } from '../../utils';
import { isItemDisabled } from './utils';

export function MultiSelect<T>(props: MultiSelectProps<T>) {
const {
Expand Down Expand Up @@ -45,12 +47,47 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
setFloating,
setReference,
isClearable,
initialHighlightedIndex,
} = props;

function checkSelectedItemValidity(itemToCheck: T) {
return (
items.findIndex(i => itemToString(i) === itemToString(itemToCheck)) !== -1
const itemIndex = items.findIndex(
i => itemToString(i) === itemToString(itemToCheck)
);

return !isItemDisabled(itemToCheck) && itemIndex !== -1 && !isItemDisabled(items[itemIndex]);
}

function getFilteredItemIndex(item: T, filteredItems: T[]) {
const index = filteredItems.findIndex(
filteredItem => itemToString(filteredItem) === itemToString(item)
);

if (isItemDisabled(filteredItems[index])) {
return -1;
}
return index;
}

function handleOnIsOpenChange(changes) {
const { isOpen: changedIsOpen, selectedItem: changedSelectedItem } =
changes;

if (changedIsOpen && changedSelectedItem) {
if (isItemDisabled(changedSelectedItem)) {
setHighlightedIndex(-1);
} else {
setHighlightedIndex(
items.findIndex(
i => itemToString(i) === itemToString(changedSelectedItem)
)
);
}
}

onIsOpenChange &&
typeof onIsOpenChange === 'function' &&
onIsOpenChange(changes);
}

const {
Expand All @@ -71,6 +108,11 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
...(props.selectedItems && {
selectedItems: props.selectedItems.filter(checkSelectedItemValidity),
}),
...(props.defaultSelectedItems && {
defaultSelectedItems: props.defaultSelectedItems.filter(
checkSelectedItemValidity
),
}),
});

function getFilteredItems(unfilteredItems) {
Expand All @@ -85,6 +127,7 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
const {
stateReducer: passedInStateReducer,
onStateChange,
onIsOpenChange,
...selectProps
} = props;

Expand All @@ -96,11 +139,26 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
...changes,
selectedItem: state.selectedItem,
};
case useSelect.stateChangeTypes.ItemClick:
case useSelect.stateChangeTypes.MenuKeyDownEnter:
if (isItemDisabled(changes.selectedItem)) {
return {
...changes,
selectedItem: state.selectedItem,
};
}
return changes;
default:
return changes;
}
}

const filteredItems = getFilteredItems(items);
const initialIndex = getFilteredItemIndex(
items[initialHighlightedIndex],
filteredItems
);

const {
isOpen,
getToggleButtonProps,
Expand All @@ -110,11 +168,14 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
getItemProps,
selectItem,
openMenu,
setHighlightedIndex,
} = useSelect({
...selectProps,
items: getFilteredItems(items),
items: filteredItems,
onSelectedItemChange: defaultOnSelectedItemChange,
stateReducer,
initialHighlightedIndex: initialIndex,
onIsOpenChange: handleOnIsOpenChange,
});

function defaultOnSelectedItemChange(changes) {
Expand All @@ -137,6 +198,9 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
const theme = React.useContext(ThemeContext);
const i18n = React.useContext(I18nContext);

const toggleButtonRef = React.useRef<HTMLButtonElement>();
const forkedtoggleButtonRef = useForkedRef(innerRef || null, toggleButtonRef);

const toggleButtonProps = getToggleButtonProps({
...getDropdownProps({
onBlur,
Expand All @@ -161,7 +225,7 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
onKeyUp: (event: any) => onKeyUp?.(event),
onFocus,
preventKeyAction: isOpen,
...(innerRef && { ref: innerRef }),
...(forkedtoggleButtonRef && { ref: forkedtoggleButtonRef }),
}),
disabled: disabled,
});
Expand All @@ -183,8 +247,6 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
return allItems.join(', ');
}

const toggleButtonRef = React.useRef<HTMLButtonElement>();

const clearIndicatori18n =
selectedItems.length > 1
? i18n.select.multi.clearIndicatorAriaLabel
Expand Down Expand Up @@ -298,9 +360,10 @@ export function MultiSelect<T>(props: MultiSelectProps<T>) {
isInverse={isInverse}
items={getFilteredItems(items)}
itemToString={itemToString}
maxHeight={itemListMaxHeight || theme.select.menu.maxHeight}
maxHeight={itemListMaxHeight ?? theme.select.menu.maxHeight}
menuStyle={menuStyle}
setFloating={setFloating}
setHighlightedIndex={setHighlightedIndex}
/>
</SelectContainer>
);
Expand Down
Loading
Loading