From bcc3d6ddd5c0b3d054ec1d229ecfa19b15236099 Mon Sep 17 00:00:00 2001 From: moathabuhamad-cengage Date: Wed, 16 Oct 2024 13:00:18 +0200 Subject: [PATCH] feat(Select): Support disabling individual items --- .changeset/feat-select-disabled-items.md | 5 + .../src/components/Select/ItemsList.tsx | 34 ++++-- .../src/components/Select/MultiSelect.test.js | 20 ++++ .../src/components/Select/MultiSelect.tsx | 77 +++++++++++-- .../src/components/Select/Select.stories.tsx | 101 ++++++++++++++++-- .../src/components/Select/Select.test.js | 29 ++++- .../src/components/Select/Select.tsx | 59 +++++++--- .../src/components/Select/components.tsx | 13 ++- .../src/components/Select/index.tsx | 34 +++--- .../src/components/Select/shared.ts | 37 +++++-- .../src/components/Select/utils.ts | 5 + .../react-magma-docs/src/pages/api/select.mdx | 73 ++++++++++++- 12 files changed, 418 insertions(+), 69 deletions(-) create mode 100644 .changeset/feat-select-disabled-items.md create mode 100644 packages/react-magma-dom/src/components/Select/utils.ts diff --git a/.changeset/feat-select-disabled-items.md b/.changeset/feat-select-disabled-items.md new file mode 100644 index 000000000..67bae619c --- /dev/null +++ b/.changeset/feat-select-disabled-items.md @@ -0,0 +1,5 @@ +--- +'react-magma-dom': minor +--- + +feat(Select): Support disabling individual items in Select and Multi Select components \ No newline at end of file diff --git a/packages/react-magma-dom/src/components/Select/ItemsList.tsx b/packages/react-magma-dom/src/components/Select/ItemsList.tsx index 57d1c052e..3a5fa2c0c 100644 --- a/packages/react-magma-dom/src/components/Select/ItemsList.tsx +++ b/packages/react-magma-dom/src/components/Select/ItemsList.tsx @@ -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 { customComponents?: SelectComponents; @@ -31,6 +34,7 @@ interface ItemsListProps { maxHeight?: number | string; menuStyle?: React.CSSProperties; setFloating?: (node: ReferenceType) => void; + setHighlightedIndex?: (index: number) => void; } const NoItemsMessage = styled.span<{ @@ -67,6 +71,7 @@ export function ItemsList(props: ItemsListProps) { maxHeight, menuStyle, setFloating, + setHighlightedIndex, } = props; const theme = React.useContext(ThemeContext); @@ -98,7 +103,7 @@ export function ItemsList(props: ItemsListProps) { } return ( -
+
(props: ItemsListProps) { const itemString = instanceOfToBeCreatedItemObject(item) ? item.label : itemToString(item); + const isDisabled = isItemDisabled(item) const { ref, ...otherDownshiftItemProps } = getItemProps({ item, index, + disabled: isDisabled, }); const key = `${itemString}${index}`; @@ -133,9 +140,16 @@ export function ItemsList(props: ItemsListProps) { itemString, key, theme, + isDisabled: isDisabled, ...otherDownshiftItemProps, }; + if (isDisabled) { + itemProps.onMouseEnter = () => { + setHighlightedIndex && setHighlightedIndex(-1); + }; + } + return {...itemProps} key={key} />; }) ) : ( diff --git a/packages/react-magma-dom/src/components/Select/MultiSelect.test.js b/packages/react-magma-dom/src/components/Select/MultiSelect.test.js index d4994e534..8502d0a4d 100644 --- a/packages/react-magma-dom/src/components/Select/MultiSelect.test.js +++ b/packages/react-magma-dom/src/components/Select/MultiSelect.test.js @@ -548,6 +548,26 @@ 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( + + ); + + const renderedSelect = getByLabelText(labelText, { selector: 'div' }); + fireEvent.click(renderedSelect); + + expect(getByText('Red')).toHaveAttribute('aria-disabled', 'true'); + expect(getByText('Red')).toHaveStyleRule('cursor', 'not-allowed'); + expect(getByText('Blue')).toHaveAttribute('aria-disabled', 'false'); + expect(getByText('Green')).toHaveAttribute('aria-disabled', 'false'); + }); + describe('events', () => { it('onBlur', () => { const onBlur = jest.fn(); diff --git a/packages/react-magma-dom/src/components/Select/MultiSelect.tsx b/packages/react-magma-dom/src/components/Select/MultiSelect.tsx index 3907c2408..59b16c9e9 100644 --- a/packages/react-magma-dom/src/components/Select/MultiSelect.tsx +++ b/packages/react-magma-dom/src/components/Select/MultiSelect.tsx @@ -1,5 +1,8 @@ import * as React from 'react'; -import { instanceOfDefaultItemObject, MultiSelectProps } from '.'; +import { + instanceOfDefaultItemObject, + MultiSelectProps, +} from '.'; import { useMultipleSelection, useSelect } from 'downshift'; import { CloseIcon } from 'react-magma-icons'; import { ItemsList } from './ItemsList'; @@ -11,6 +14,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(props: MultiSelectProps) { const { @@ -45,14 +50,48 @@ export function MultiSelect(props: MultiSelectProps) { setFloating, setReference, isClearable, + initialHighlightedIndex, } = props; function checkSelectedItemValidity(itemToCheck: T) { return ( + !isItemDisabled(itemToCheck) && items.findIndex(i => itemToString(i) === itemToString(itemToCheck)) !== -1 ); } + 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 { getSelectedItemProps, getDropdownProps, @@ -71,6 +110,11 @@ export function MultiSelect(props: MultiSelectProps) { ...(props.selectedItems && { selectedItems: props.selectedItems.filter(checkSelectedItemValidity), }), + ...(props.defaultSelectedItems && { + defaultSelectedItems: props.defaultSelectedItems.filter( + checkSelectedItemValidity + ), + }), }); function getFilteredItems(unfilteredItems) { @@ -85,6 +129,7 @@ export function MultiSelect(props: MultiSelectProps) { const { stateReducer: passedInStateReducer, onStateChange, + onIsOpenChange, ...selectProps } = props; @@ -96,11 +141,26 @@ export function MultiSelect(props: MultiSelectProps) { ...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, @@ -110,11 +170,14 @@ export function MultiSelect(props: MultiSelectProps) { getItemProps, selectItem, openMenu, + setHighlightedIndex, } = useSelect({ ...selectProps, - items: getFilteredItems(items), + items: filteredItems, onSelectedItemChange: defaultOnSelectedItemChange, stateReducer, + initialHighlightedIndex: initialIndex, + onIsOpenChange: handleOnIsOpenChange, }); function defaultOnSelectedItemChange(changes) { @@ -137,6 +200,9 @@ export function MultiSelect(props: MultiSelectProps) { const theme = React.useContext(ThemeContext); const i18n = React.useContext(I18nContext); + const toggleButtonRef = React.useRef(); + const forkedtoggleButtonRef = useForkedRef(innerRef || null, toggleButtonRef); + const toggleButtonProps = getToggleButtonProps({ ...getDropdownProps({ onBlur, @@ -161,7 +227,7 @@ export function MultiSelect(props: MultiSelectProps) { onKeyUp: (event: any) => onKeyUp?.(event), onFocus, preventKeyAction: isOpen, - ...(innerRef && { ref: innerRef }), + ...(forkedtoggleButtonRef && { ref: forkedtoggleButtonRef }), }), disabled: disabled, }); @@ -183,8 +249,6 @@ export function MultiSelect(props: MultiSelectProps) { return allItems.join(', '); } - const toggleButtonRef = React.useRef(); - const clearIndicatori18n = selectedItems.length > 1 ? i18n.select.multi.clearIndicatorAriaLabel @@ -298,9 +362,10 @@ export function MultiSelect(props: MultiSelectProps) { 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} /> ); diff --git a/packages/react-magma-dom/src/components/Select/Select.stories.tsx b/packages/react-magma-dom/src/components/Select/Select.stories.tsx index cbe6c49f4..3d0c41bce 100644 --- a/packages/react-magma-dom/src/components/Select/Select.stories.tsx +++ b/packages/react-magma-dom/src/components/Select/Select.stories.tsx @@ -1,13 +1,13 @@ +import { Meta, Story } from '@storybook/react/types-6-0'; import React from 'react'; -import { Story, Meta } from '@storybook/react/types-6-0'; -import { Select, SelectOptions, SelectProps, MultiSelectProps } from './'; -import { LabelPosition } from '../Label'; +import { HelpIcon } from 'react-magma-icons'; +import { ButtonSize, ButtonType, ButtonVariant } from '../Button'; import { Card } from '../Card'; import { CardBody } from '../Card/CardBody'; -import { Tooltip } from '../Tooltip'; import { IconButton } from '../IconButton'; -import { HelpIcon } from 'react-magma-icons'; -import { ButtonSize, ButtonType, ButtonVariant } from '../Button'; +import { LabelPosition } from '../Label'; +import { Tooltip } from '../Tooltip'; +import { MultiSelectProps, Select, SelectOptions, SelectProps } from './'; const Template: Story> = args => ( + ); + + const renderedSelect = getByLabelText(labelText, { selector: 'div' }); + fireEvent.click(renderedSelect); + + expect(getByText('Red')).toHaveAttribute('aria-disabled', 'true'); + expect(getByText('Red')).toHaveStyleRule('cursor', 'not-allowed'); + expect(getByText('Blue')).toHaveAttribute('aria-disabled', 'false'); + expect(getByText('Green')).toHaveAttribute('aria-disabled', 'false'); + }); }); describe('events', () => { diff --git a/packages/react-magma-dom/src/components/Select/Select.tsx b/packages/react-magma-dom/src/components/Select/Select.tsx index a3b672cba..2126fce04 100644 --- a/packages/react-magma-dom/src/components/Select/Select.tsx +++ b/packages/react-magma-dom/src/components/Select/Select.tsx @@ -1,16 +1,17 @@ -import * as React from 'react'; -import { SelectProps } from '.'; import { useSelect } from 'downshift'; -import { SelectText } from './shared'; -import { defaultComponents } from './components'; +import * as React from 'react'; import { CloseIcon } from 'react-magma-icons'; +import { SelectProps } from '.'; +import { I18nContext } from '../../i18n'; +import { ThemeContext } from '../../theme/ThemeContext'; +import { useForkedRef } from '../../utils'; import { ButtonSize, ButtonVariant } from '../Button'; +import { defaultComponents } from './components'; import { ItemsList } from './ItemsList'; import { SelectContainer } from './SelectContainer'; import { SelectTriggerButton } from './SelectTriggerButton'; -import { ThemeContext } from '../../theme/ThemeContext'; -import { I18nContext } from '../../i18n'; -import { useForkedRef } from '../../utils'; +import { SelectText } from './shared'; +import { isItemDisabled } from './utils'; export function Select(props: SelectProps) { const { @@ -48,6 +49,7 @@ export function Select(props: SelectProps) { selectedItem: passedInSelectedItem, setReference, setFloating, + initialHighlightedIndex, } = props; const toggleButtonRef = React.useRef(); @@ -56,7 +58,15 @@ export function Select(props: SelectProps) { const ref = useForkedRef(innerRef || null, toggleButtonRef); + function isDisabledItemIndex(index) { + return isItemDisabled(items[index]); + } + function getValidItem(itemToCheck: T, key: string): object { + if (isItemDisabled(itemToCheck)) { + return { [key]: null }; + } + return items.findIndex( i => itemToString(i) === itemToString(itemToCheck) ) !== -1 @@ -64,16 +74,28 @@ export function Select(props: SelectProps) { : { [key]: null }; } + function getValidItemIndex(indexToCheck: number) { + if (isDisabledItemIndex(indexToCheck)) { + return -1; + } else { + return indexToCheck; + } + } + function handleOnIsOpenChange(changes) { const { isOpen: changedIsOpen, selectedItem: changedSelectedItem } = changes; if (changedIsOpen && changedSelectedItem) { - setHighlightedIndex( - items.findIndex( - i => itemToString(i) === itemToString(changedSelectedItem) - ) - ); + if (isItemDisabled(changedSelectedItem)) { + setHighlightedIndex(-1); + } else { + setHighlightedIndex( + items.findIndex( + i => itemToString(i) === itemToString(changedSelectedItem) + ) + ); + } } onIsOpenChange && @@ -89,6 +111,15 @@ export function Select(props: SelectProps) { ...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; } @@ -107,6 +138,7 @@ export function Select(props: SelectProps) { setHighlightedIndex, } = useSelect({ ...props, + initialHighlightedIndex: getValidItemIndex(initialHighlightedIndex), onIsOpenChange: handleOnIsOpenChange, stateReducer, ...(defaultSelectedItem && @@ -226,9 +258,10 @@ export function Select(props: SelectProps) { isOpen={isOpen} items={items} itemToString={itemToString} - maxHeight={itemListMaxHeight || theme.select.menu.maxHeight} + maxHeight={itemListMaxHeight ?? theme.select.menu.maxHeight} menuStyle={menuStyle} setFloating={setFloating} + setHighlightedIndex={setHighlightedIndex} /> ); diff --git a/packages/react-magma-dom/src/components/Select/components.tsx b/packages/react-magma-dom/src/components/Select/components.tsx index 847689d46..ee5f20fcd 100644 --- a/packages/react-magma-dom/src/components/Select/components.tsx +++ b/packages/react-magma-dom/src/components/Select/components.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; +import { ArrowDropDownIcon, IconProps } from 'react-magma-icons'; +import { ThemeInterface } from '../../theme/magma'; import { IconButton, IconButtonProps } from '../IconButton'; import { Spinner, SpinnerProps } from '../Spinner'; -import { IconProps, ArrowDropDownIcon } from 'react-magma-icons'; import { StyledItem } from './shared'; -import { ThemeInterface } from '../../theme/magma'; export type ItemRenderOptions = { key: string; @@ -46,10 +46,17 @@ export function DefaultItem({ itemRef, itemString, isInverse, + isDisabled, ...props }: ItemRenderOptions) { return ( - + {itemString} ); diff --git a/packages/react-magma-dom/src/components/Select/index.tsx b/packages/react-magma-dom/src/components/Select/index.tsx index 0443eec60..982223a0b 100644 --- a/packages/react-magma-dom/src/components/Select/index.tsx +++ b/packages/react-magma-dom/src/components/Select/index.tsx @@ -1,10 +1,3 @@ -import * as React from 'react'; -import { - useMultipleSelection, - UseMultipleSelectionProps, - useSelect, - UseSelectProps, -} from 'downshift'; import { AlignedPlacement, autoUpdate, @@ -12,16 +5,23 @@ import { useFloating, } from '@floating-ui/react-dom'; import { ReferenceType } from '@floating-ui/react-dom/dist/floating-ui.react-dom'; -import { Select as InternalSelect } from './Select'; -import { MultiSelect } from './MultiSelect'; -import { SelectComponents } from './components'; +import { + useMultipleSelection, + UseMultipleSelectionProps, + useSelect, + UseSelectProps, +} from 'downshift'; +import * as React from 'react'; +import { useIsInverse } from '../../inverse'; import { Omit, useGenerateId, XOR } from '../../utils'; import { LabelPosition } from '../Label'; -import { useIsInverse } from '../../inverse'; +import { MultiSelect } from './MultiSelect'; +import { Select as InternalSelect } from './Select'; +import { SelectComponents } from './components'; export type SelectOptions = | string - | { value: string; label: string; [key: string]: any } + | { value: string; label: string; [key: string]: any; disabled?: boolean } | any; export interface InternalSelectProps { @@ -142,6 +142,10 @@ export interface SelectProps * @internal */ isMulti?: false; + /** + * Index of the item that should be highlighted by default. Use this prop when you want to set a specific item on the list to be highlighted when the component is first rendered. + */ + initialHighlightedIndex?: number; /** * Event that fires when the trigger button loses focus */ @@ -214,6 +218,12 @@ export function instanceOfToBeCreatedItemObject(object: any): object is { ); } +export function instanceOfItemWithOptionalDisabled( + object: any +): object is { label: string; value: string; disabled?: boolean } { + return typeof object !== 'string' && object && 'disabled' in object; +} + export type XORSelectProps = XOR, MultiSelectProps>; export const SelectStateChangeTypes = useSelect.stateChangeTypes; diff --git a/packages/react-magma-dom/src/components/Select/shared.ts b/packages/react-magma-dom/src/components/Select/shared.ts index cbc9c4113..83dd75a68 100644 --- a/packages/react-magma-dom/src/components/Select/shared.ts +++ b/packages/react-magma-dom/src/components/Select/shared.ts @@ -1,9 +1,9 @@ -import { inputBaseStyles } from '../InputBase'; -import { Card } from '../Card'; -import { transparentize } from 'polished'; +import { css } from '@emotion/react'; import styled from '@emotion/styled'; +import { transparentize } from 'polished'; import { ThemeInterface } from '../../theme/magma'; -import { css } from '@emotion/react'; +import { Card } from '../Card'; +import { inputBaseStyles } from '../InputBase'; function buildListHoverColor(props) { if (props.isFocused) { @@ -25,6 +25,19 @@ function buildListFocusColor(props) { return 'transparent'; } +function buildListItemColor(props) { + if (props.isDisabled) { + if (props.isInverse) { + return transparentize(0.6, props.theme.colors.neutral100); + } + return transparentize(0.4, props.theme.colors.neutral500); + } + if (props.isInverse) { + return props.theme.colors.neutral100; + } + return props.theme.colors.neutral700; +} + export const SelectContainer = styled.div` position: relative; `; @@ -55,9 +68,12 @@ export const SelectText = styled.span<{ : props.theme.colors.neutral500; } }}; - ${props => props.isDisabled && props.isShowPlaceholder && css` - opacity: ${props.isInverse ? 0.4 : 0.6} - `} + ${props => + props.isDisabled && + props.isShowPlaceholder && + css` + opacity: ${props.isInverse ? 0.4 : 0.6}; + `} `; export const StyledCard = styled(Card)<{ @@ -91,19 +107,18 @@ export const StyledList = styled('ul')<{ isOpen?: boolean; maxHeight: string }>` export const StyledItem = styled('li')<{ isInverse?: boolean; isFocused?: boolean; + isDisabled?: boolean; }>` align-self: center; background: ${props => buildListHoverColor(props)}; border: 2px solid; border-color: ${props => buildListFocusColor(props)}; cursor: default; - color: ${props => - props.isInverse - ? props.theme.colors.neutral100 - : props.theme.colors.neutral700}; + color: ${props => buildListItemColor(props)}; line-height: 24px; margin: 0; padding: 8px 16px; + cursor: ${props => (props.isDisabled ? 'not-allowed' : 'default')}; &:hover { background: ${props => buildListHoverColor(props)}; border-color: transparent; diff --git a/packages/react-magma-dom/src/components/Select/utils.ts b/packages/react-magma-dom/src/components/Select/utils.ts new file mode 100644 index 000000000..3bd6c874b --- /dev/null +++ b/packages/react-magma-dom/src/components/Select/utils.ts @@ -0,0 +1,5 @@ +import { instanceOfItemWithOptionalDisabled } from "."; + +export function isItemDisabled(item) { + return instanceOfItemWithOptionalDisabled(item) && item.disabled; +} diff --git a/website/react-magma-docs/src/pages/api/select.mdx b/website/react-magma-docs/src/pages/api/select.mdx index 2d84e5e46..4a5f51939 100644 --- a/website/react-magma-docs/src/pages/api/select.mdx +++ b/website/react-magma-docs/src/pages/api/select.mdx @@ -159,6 +159,75 @@ export function Example() { } ``` +## Disabled Items + +To disable specific items in the `Select` component, add a `disabled: true` property to each item object. Disabled items specified as `defaultSelectedItem` or `initialSelectedItem` will not be selected by default or initially. If `initialHighlightedIndex` points to a `disabled` item, the component will not highlight it. + +Example: + +```tsx +import React from 'react'; +import { Select } from 'react-magma-dom'; + +export function Example() { + return ( + <> + + + ); +} +``` + ## Clearable The optional `isClearable` prop allows the user to clear the field once a selection has been made. @@ -1145,7 +1214,9 @@ corresponds to a `stateChangeTypes` property. The list of all the possible types ). However, in the production environment the types equate to numbers
- (eg: ComboboxStateChangeTypes.InputKeyDownArrowDown = 0). + (eg: + ComboboxStateChangeTypes.InputKeyDownArrowDown = 0 + ). ### Select