From 5b5e09ba26477d1f8d58474152dbb1d323e9972b Mon Sep 17 00:00:00 2001 From: Janneke van Hulten <50013337+janseke10@users.noreply.github.com> Date: Fri, 8 Nov 2024 10:34:13 +0100 Subject: [PATCH] feat(PrioritySelector): new component (#1308) Co-authored-by: Eszter Co-authored-by: Oleksii Mukiienko --- .../Items/MultiSelectItem/MultiSelectItem.tsx | 88 +++++---- .../SingleSelectItem/SingleSelectItem.tsx | 74 ++++--- .../PrioritySelector/PrioritySelector.scss | 117 ++++++++++++ .../PrioritySelector/PrioritySelector.tsx | 149 +++++++++++++++ .../__tests__/PrioritySelector.spec.tsx | 109 +++++++++++ .../PrioritySelector.spec.tsx.snap | 180 ++++++++++++++++++ src/components/PrioritySelector/index.ts | 6 + .../SelectedItemBadge/SelectedItemBadge.tsx | 94 +-------- src/components/SelectedItemBadge/index.ts | 7 +- src/index.ts | 1 + src/utils/misc.ts | 4 + stories/atoms/Dropdown.stories.tsx | 8 +- stories/atoms/MultiSelectItem.stories.tsx | 61 +++++- stories/atoms/SingleSelectItem.stories.tsx | 65 ++++++- .../molecules/PrioritySelector.stories.tsx | 46 +++++ .../organisms/SelectedItemBadge.stories.tsx | 20 +- 16 files changed, 863 insertions(+), 166 deletions(-) create mode 100644 src/components/PrioritySelector/PrioritySelector.scss create mode 100644 src/components/PrioritySelector/PrioritySelector.tsx create mode 100644 src/components/PrioritySelector/__tests__/PrioritySelector.spec.tsx create mode 100644 src/components/PrioritySelector/__tests__/__snapshots__/PrioritySelector.spec.tsx.snap create mode 100644 src/components/PrioritySelector/index.ts create mode 100644 stories/molecules/PrioritySelector.stories.tsx diff --git a/src/components/Dropdown/Items/MultiSelectItem/MultiSelectItem.tsx b/src/components/Dropdown/Items/MultiSelectItem/MultiSelectItem.tsx index 83c94b4f9..88e26eb5c 100644 --- a/src/components/Dropdown/Items/MultiSelectItem/MultiSelectItem.tsx +++ b/src/components/Dropdown/Items/MultiSelectItem/MultiSelectItem.tsx @@ -1,59 +1,83 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ import React from 'react'; import { DropdownMenuCheckboxItem, DropdownMenuCheckboxItemProps, } from '@radix-ui/react-dropdown-menu'; +import { PrioritySelector, PrioritySelectorProps } from '../../../PrioritySelector'; import { bem } from '../../../../utils'; import { VisualCheckbox } from '../../../Checkbox'; import { Text } from '../../../Text'; import styles from './MultiSelectItem.scss'; +import { stopPropagation } from '../../../../utils/misc'; -export interface Props extends DropdownMenuCheckboxItemProps { +export interface Props extends DropdownMenuCheckboxItemProps { /** Id for the checkbox */ id?: string; /** a variant to determine the look and feel of component */ variant?: 'option' | 'select-all' | 'group-title'; - /** to add a priority badge before the label */ - hasPriority?: boolean; + /** props for PrioritySelector */ + priority?: PrioritySelectorProps; /** Checkbox status */ isSelected?: boolean; } const { block } = bem('MultiSelectItem', styles); -export const MultiSelectItem = React.forwardRef( - ( +export const MultiSelectItem = React.forwardRef( + ( { children, isSelected = false, disabled = false, variant = 'option', - hasPriority = false, // eslint-disable-line @typescript-eslint/no-unused-vars onCheckedChange, + priority, ...rest - }, - ref - ) => ( - { - e.preventDefault(); - }} - onCheckedChange={onCheckedChange} - {...rest} - {...block({ - isSelected, - disabled, - variant, - ...rest, - })} - > - - {children} - - ) -); + }: Props, + ref: React.Ref + ) => { + const priorityRef = React.useRef(null); + + const hasPriorityList = priority && priority.list.length > 0; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey) { + if (priorityRef.current) { + priorityRef.current.focus(); + } + } + }; + + return ( + { + e.preventDefault(); + }} + onCheckedChange={onCheckedChange} + {...rest} + {...block({ + isSelected, + disabled, + variant, + ...rest, + })} + > + + {hasPriorityList && ( +
+ +
+ )} + {children} +
+ ); + } +) as ( + p: Props & { ref?: React.Ref } +) => React.ReactElement; diff --git a/src/components/Dropdown/Items/SingleSelectItem/SingleSelectItem.tsx b/src/components/Dropdown/Items/SingleSelectItem/SingleSelectItem.tsx index cc893a5c4..1b322ddf7 100644 --- a/src/components/Dropdown/Items/SingleSelectItem/SingleSelectItem.tsx +++ b/src/components/Dropdown/Items/SingleSelectItem/SingleSelectItem.tsx @@ -1,45 +1,69 @@ +/* eslint-disable @typescript-eslint/no-unnecessary-type-constraint */ import React from 'react'; import { DropdownMenuItem, DropdownMenuItemProps } from '@radix-ui/react-dropdown-menu'; +import { PrioritySelector, PrioritySelectorProps } from '../../../PrioritySelector'; import { bem } from '../../../../utils'; import styles from '../Item.scss'; +import { stopPropagation } from '../../../../utils/misc'; -export interface Props extends Omit { +export interface Props extends Omit { /** A function to be called if the item is clicked */ onSelect?: (e: React.SyntheticEvent) => void; /** Id for the checkbox */ id?: string; - /** to add a priority badge before the label */ - hasPriority?: boolean; /** Checkbox status */ isSelected?: boolean; + /** props for PrioritySelector */ + priority?: PrioritySelectorProps; } const { block } = bem('DropdownItem', styles); -export const SingleSelectItem = React.forwardRef( - ( +export const SingleSelectItem = React.forwardRef( + ( { children, isSelected = false, disabled = false, - hasPriority = false, // eslint-disable-line @typescript-eslint/no-unused-vars + priority, ...rest - }, - ref - ) => ( - - {children} - - ) -); + }: Props, + ref: React.Ref + ) => { + const hasPriorityList = priority && priority.list.length > 0; + const priorityRef = React.useRef(null); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Tab' && !e.shiftKey) { + if (priorityRef.current) { + priorityRef.current.focus(); + } + } + }; + + return ( + + {hasPriorityList && ( +
+ +
+ )} + {children} +
+ ); + } +) as ( + p: Props & { ref?: React.Ref } +) => React.ReactElement; diff --git a/src/components/PrioritySelector/PrioritySelector.scss b/src/components/PrioritySelector/PrioritySelector.scss new file mode 100644 index 000000000..9f45076c8 --- /dev/null +++ b/src/components/PrioritySelector/PrioritySelector.scss @@ -0,0 +1,117 @@ +@use 'sass:map'; +@mixin common-button-style( + $hoverColor: var(--color-background-neutral-subtlest-hover), + $activeColor: var(--color-background-neutral-subtlest-pressed) +) { + & { + width: var(--space-400); + height: var(--space-400); + align-items: center; + background-color: var(--transparent); + cursor: pointer; + display: flex; + flex-shrink: 0; + justify-content: center; + } + &:hover:not([disabled]) { + background-color: $hoverColor; + } + &:active:not([disabled]) { + background-color: $activeColor; + } + &[disabled] { + cursor: not-allowed; + } +} +@mixin selected-state( + $selectedColor: var(--color-background-selected-subtlest-default), + $hoverColor: var(--color-background-selected-subtlest-hover), + $activeColor: var(--color-background-selected-subtlest-pressed) +) { + & { + background-color: $selectedColor; + } + &:hover:not([disabled]) { + background-color: $hoverColor; + } + &:active:not([disabled]) { + background-color: $activeColor; + } + &[disabled] { + background-color: $selectedColor; + } +} +$icon-status-colors: ( + mandatory: ( + normal: var(--color-icon-success-default), + disabled: var(--color-icon-success-disabled), + ), + important: ( + normal: var(--color-icon-caution-default), + disabled: var(--color-icon-caution-disabled), + ), + optional: ( + normal: var(--color-icon-subtle), + disabled: var(--color-icon-disabled), + ), + exclude: ( + normal: var(--color-icon-critical-default), + disabled: var(--color-icon-critical-disabled), + ), +); +.PrioritySelector { + width: 100%; + @each $status, $colors in $icon-status-colors { + &--#{$status} { + fill: map.get($colors, normal); + &[disabled] { + fill: map.get($colors, disabled); + } + } + } + + &__icon { + outline: none; + min-height: 20px; + min-width: 20px; + @each $status, $colors in $icon-status-colors { + &--#{$status} { + fill: map.get($colors, normal); + &[disabled] { + fill: map.get($colors, disabled); + } + } + } + + &--inList { + height: 20px; + width: 20px; + } + } + &__priorityButton--isSelected, + &__optionButton--isSelected { + @include selected-state(); + } + + &__badgeListItem { + display: flex; + align-items: center; + justify-content: flex-start; + gap: var(--space-100); + } + &__badgeDropdownList { + max-width: 400px; + border-radius: var(--space-100); + overflow: hidden; + background-color: white; + + &--fixedWidth{ + width: 232px; + } + } + +} + + + + diff --git a/src/components/PrioritySelector/PrioritySelector.tsx b/src/components/PrioritySelector/PrioritySelector.tsx new file mode 100644 index 000000000..6dcd913b8 --- /dev/null +++ b/src/components/PrioritySelector/PrioritySelector.tsx @@ -0,0 +1,149 @@ +import * as React from 'react'; +import Close from '@material-design-icons/svg/round/close.svg'; +import KeyboardDoubleArrowUp from '@material-design-icons/svg/round/keyboard_double_arrow_up.svg'; +import KeyboardArrowUp from '@material-design-icons/svg/round/keyboard_arrow_up.svg'; +import KeyboardArrowDown from '@material-design-icons/svg/round/keyboard_arrow_down.svg'; +import { DropdownMenuItem, Root, Portal } from '@radix-ui/react-dropdown-menu'; +import { Size } from '@textkernel/oneui'; +import { DropdownTrigger } from '../Dropdown/DropdownTrigger'; +import { DropdownContent } from '../Dropdown/DropdownContent'; +import { bem } from '../../utils/bem'; +import { Text } from '../Text'; +import { IconButton } from '../Buttons'; +import itemStyles from '../Dropdown/Items/Item.scss'; +import styles from './PrioritySelector.scss'; + +const iconMap = { + mandatory: KeyboardDoubleArrowUp, + important: KeyboardArrowUp, + optional: KeyboardArrowDown, + exclude: Close, +}; + +export type Priority = 'mandatory' | 'important' | 'optional' | 'exclude'; + +export type PriorityItemType = { + /** priority types: mandatory, important, optional or exclude. Makes correct icon show up */ + priority: Priority; + /** text that should accompany the icon in the dropdown */ + label: string; + /** optional: used in case of application-specific values / custom priorities */ + value?: PriorityItemValue; +}; + +export interface Props + extends Omit, 'onChange'> { + /** Currently selected priority item that indicates the importance of the component. */ + selectedItem: PriorityItemType; + /** Array of availible priority items. */ + list: PriorityItemType[]; + /** Callback function triggered when a new priority is selected. */ + onChange: (newPriorityItem: PriorityItemType) => void; + /** Boolean indicating whether the whole badge should be disabled. */ + isDisabled?: boolean; + /** Priority button label name for ARIA labelling */ + buttonLabel?: string; + /** ref to possible parent div, if it is a badge */ + parentRef?: React.RefObject | React.ForwardedRef; + /** ref to iconButton, used for keyboard navigation */ + buttonRef?: React.RefObject; + /** the size of the trigger button */ + size?: Size; +} + +const { block, elem } = bem('PrioritySelector', styles); +const itemStylesBem = bem('DropdownItem', itemStyles); +export function PrioritySelector({ + onChange, + selectedItem, + isDisabled = false, + buttonLabel, + list, + parentRef, + buttonRef, + size, + ...rest +}: Props) { + const renderPriorityIcon = ( + priorityType?: Priority, + disabled: boolean = false, + inList?: boolean + ) => { + if (!priorityType) { + return null; + } + const IconComponent = iconMap[priorityType]; + return IconComponent ? ( + + ) : null; + }; + + const priorityOrder: Record = { + mandatory: 1, + important: 2, + optional: 3, + exclude: 4, + }; + + // Sort the list based on the priority order + const sortedList = list + .slice() + .sort((a, b) => priorityOrder[a.priority] - priorityOrder[b.priority]); + + return ( + + + + {renderPriorityIcon(selectedItem.priority, isDisabled)} + + + + + {sortedList.map((item) => ( + { + e.stopPropagation(); + onChange(item); + }} + role="option" + aria-selected={selectedItem.priority === item.priority} + tabIndex={isDisabled ? -1 : 0} + {...rest} + {...itemStylesBem.block({ + isSelected: selectedItem === item, + disabled: isDisabled, + ...rest, + })} + > +
+ {renderPriorityIcon(item.priority, false, true)} + + {item.label} + +
+
+ ))} +
+
+
+ ); +} + +PrioritySelector.displayName = 'PrioritySelector'; diff --git a/src/components/PrioritySelector/__tests__/PrioritySelector.spec.tsx b/src/components/PrioritySelector/__tests__/PrioritySelector.spec.tsx new file mode 100644 index 000000000..8ea7ff411 --- /dev/null +++ b/src/components/PrioritySelector/__tests__/PrioritySelector.spec.tsx @@ -0,0 +1,109 @@ +import * as React from 'react'; +import { render, RenderResult, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import { userEvent } from '@testing-library/user-event'; +import { PriorityItemType, PrioritySelector } from '../PrioritySelector'; + +const priorityList: PriorityItemType[] = [ + { priority: 'mandatory', label: 'Mandatory', value: 'required' }, + { priority: 'important', label: 'Important', value: 'strongly_favored' }, + { priority: 'optional', label: 'Optional', value: 'favored' }, + { priority: 'exclude', label: 'Exclude', value: 'banned' }, +]; + +const priorityListRandom: PriorityItemType[] = [ + { priority: 'optional', label: 'Optional', value: 'favored' }, + { priority: 'mandatory', label: 'Mandatory', value: 'required' }, + { priority: 'exclude', label: 'Exclude', value: 'banned' }, + { priority: 'important', label: 'Important', value: 'strongly_favored' }, +]; + +describe('PrioritySelector', () => { + let view: RenderResult; + const onSelectMock = jest.fn(); + + beforeEach(() => { + view = render( + + ); + }); + afterEach(() => { + jest.resetAllMocks(); + }); + + it('renders correctly with all props provided', () => { + expect(view.container).toMatchSnapshot(); + expect(screen.getByRole('button', { name: 'Mandatory' })).toBeInTheDocument(); + }); + + it('should render correctly opened', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Mandatory' })); + expect(view.baseElement).toMatchSnapshot(); + expect(screen.getAllByRole('option')).toHaveLength(4); + }); + + it('should call onSelect correctly', async () => { + const user = userEvent.setup(); + expect(onSelectMock).toHaveBeenCalledTimes(0); + + await user.click(screen.getByRole('button', { name: 'Mandatory' })); + await user.click(screen.getByText('Important')); + + expect(onSelectMock).toHaveBeenCalledTimes(1); + expect(onSelectMock).toHaveBeenCalledWith(priorityList[1]); + }); + + it('should close dropdown when item is chosen', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Mandatory' })); + + expect(screen.getByRole('menu')).toHaveAttribute('data-state', 'open'); + await user.click(screen.getAllByRole('option')[1]); + + expect(screen.getByRole('button')).toHaveAttribute('data-state', 'closed'); + }); + + it('should render the priorities in the correct order', async () => { + const user = userEvent.setup(); + await user.click(screen.getByRole('button', { name: 'Mandatory' })); + + expect(screen.getAllByTestId('default-icon')[1]).toHaveClass( + 'PrioritySelector__icon--mandatory' + ); + expect(screen.getAllByTestId('default-icon')[2]).toHaveClass( + 'PrioritySelector__icon--important' + ); + expect(screen.getAllByTestId('default-icon')[3]).toHaveClass( + 'PrioritySelector__icon--optional' + ); + expect(screen.getAllByTestId('default-icon')[4]).toHaveClass( + 'PrioritySelector__icon--exclude' + ); + + view.rerender( + + ); + + expect(screen.getAllByTestId('default-icon')[1]).toHaveClass( + 'PrioritySelector__icon--mandatory' + ); + expect(screen.getAllByTestId('default-icon')[2]).toHaveClass( + 'PrioritySelector__icon--important' + ); + expect(screen.getAllByTestId('default-icon')[3]).toHaveClass( + 'PrioritySelector__icon--optional' + ); + expect(screen.getAllByTestId('default-icon')[4]).toHaveClass( + 'PrioritySelector__icon--exclude' + ); + }); +}); diff --git a/src/components/PrioritySelector/__tests__/__snapshots__/PrioritySelector.spec.tsx.snap b/src/components/PrioritySelector/__tests__/__snapshots__/PrioritySelector.spec.tsx.snap new file mode 100644 index 000000000..a68b897e7 --- /dev/null +++ b/src/components/PrioritySelector/__tests__/__snapshots__/PrioritySelector.spec.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PrioritySelector renders correctly with all props provided 1`] = ` +
+ +
+`; + +exports[`PrioritySelector should render correctly opened 1`] = ` + +