diff --git a/packages/react/src/components/date-picker/calendar-header.tsx b/packages/react/src/components/date-picker/calendar-header.tsx index 52d939d895..003def2957 100644 --- a/packages/react/src/components/date-picker/calendar-header.tsx +++ b/packages/react/src/components/date-picker/calendar-header.tsx @@ -89,7 +89,7 @@ export const CalendarHeader: VoidFunctionComponent = ({ ariaLabel={t('monthSelectLabel')} data-testid="month-select" options={monthsOptions} - onChange={(options) => { + onChange={(options: DropdownListOption) => { changeMonth(months.indexOf(options.label)); }} value={monthsOptions[getMonth(date)].value} @@ -100,7 +100,7 @@ export const CalendarHeader: VoidFunctionComponent = ({ ariaLabel={t('yearSelectLabel')} data-testid="year-select" options={yearsOptions} - onChange={(options) => { + onChange={(options: DropdownListOption) => { changeYear(parseInt(options.value, 10)); }} value={getYear(date).toString()} diff --git a/packages/react/src/components/date-picker/date-picker.test.tsx.snap b/packages/react/src/components/date-picker/date-picker.test.tsx.snap index dc9517550f..c5c91d4a7f 100644 --- a/packages/react/src/components/date-picker/date-picker.test.tsx.snap +++ b/packages/react/src/components/date-picker/date-picker.test.tsx.snap @@ -2024,11 +2024,11 @@ input + .c1 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2x); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2x); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -2446,7 +2446,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a month" - aria-labelledby="uuid2_label" + aria-labelledby="uuid2_label uuid2_listbox_october_label" aria-required="false" class="c12" data-testid="month-select" @@ -2488,7 +2488,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a year" - aria-labelledby="uuid3_label" + aria-labelledby="uuid3_label uuid3_listbox_2010_label" aria-required="false" class="c12" data-testid="year-select" @@ -3252,11 +3252,11 @@ input + .c1 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2x); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2x); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -3680,7 +3680,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a month" - aria-labelledby="uuid2_label" + aria-labelledby="uuid2_label uuid2_listbox_october_label" aria-required="false" class="c12" data-testid="month-select" @@ -3722,7 +3722,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a year" - aria-labelledby="uuid3_label" + aria-labelledby="uuid3_label uuid3_listbox_2010_label" aria-required="false" class="c12" data-testid="year-select" @@ -4413,11 +4413,11 @@ input + .c1 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2halfx); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2halfx); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -4833,7 +4833,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a month" - aria-labelledby="uuid2_label" + aria-labelledby="uuid2_label uuid2_listbox_october_label" aria-required="false" class="c12" data-testid="month-select" @@ -4875,7 +4875,7 @@ label + .c3 { aria-expanded="false" aria-invalid="false" aria-label="Select a year" - aria-labelledby="uuid3_label" + aria-labelledby="uuid3_label uuid3_listbox_2010_label" aria-required="false" class="c12" data-testid="year-select" diff --git a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx index 0cd1d9906f..6bba658ea7 100644 --- a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx +++ b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx @@ -91,6 +91,21 @@ describe('Dropdown list', () => { const wrapper = shallow(); expect(getByTestId(wrapper, 'textbox').prop('value')).toBe(''); + }); + + test('the specified defaultValues are independently displayed when list is multiselect', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'listboxtag-qc').exists()).toBe(true); + expect(getByTestId(wrapper, 'listboxtag-nl').exists()).toBe(true); + expect(getByTestId(wrapper, 'tag-wrapper').children()).toHaveLength(2); + expect(getByTestId(wrapper, 'input').prop('value')).toBe('nl|qc'); + }); + + test('no defaultValues are displayed when not specified and list is multiselect', () => { + const wrapper = shallow(); + + expect(getByTestId(wrapper, 'tag-wrapper').children()).toHaveLength(0); expect(getByTestId(wrapper, 'input').prop('value')).toBe(''); }); }); @@ -129,6 +144,16 @@ describe('Dropdown list', () => { expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('bc'); }); + + test('clicking an option selects it and adds it to the input values when list is multiselect', () => { + const wrapper = mountWithTheme(); + + getByTestId(wrapper, 'listitem-nl').simulate('click'); + getByTestId(wrapper, 'listitem-qc').simulate('click'); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('nl|qc'); + expect(getByTestId(wrapper, 'input').prop('value')).toBe('nl|qc'); + }); }); describe('component is controlled', () => { @@ -313,6 +338,20 @@ describe('Dropdown list', () => { expect(getByTestId(wrapper, 'input').prop('value')).toBe('sk'); }); + test('Enter removes the focused Tag when list is multiselect', () => { + const wrapper = mountWithTheme( + , + ); + + getByTestId(wrapper, 'listboxtag-bc').simulate( + 'keydown', + { key: 'Enter', preventDefault: jest.fn() }, + ); + + expect(getByTestId(wrapper, 'textbox').prop('value')).toBe('ab'); + expect(getByTestId(wrapper, 'input').prop('value')).toBe('ab'); + }); + describe('when typing printable characters', () => { test('listbox opens when typing printable characters', () => { const wrapper = shallow(); @@ -427,4 +466,16 @@ describe('Dropdown list', () => { expect(tree).toMatchSnapshot(); }); + + test('matches the snapshot (multiselect)', () => { + const tree = renderWithProviders( + , + ); + + expect(tree).toMatchSnapshot(); + }); }); diff --git a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap index 3750104e6d..422ebcab83 100644 --- a/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap +++ b/packages/react/src/components/dropdown-list/dropdown-list.test.tsx.snap @@ -162,11 +162,11 @@ input + .c2 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2x); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2x); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -230,7 +230,7 @@ input + .c2 { aria-controls="uuid1_listbox" aria-expanded="true" aria-invalid="false" - aria-labelledby="uuid1_label" + aria-labelledby="uuid1_label uuid1_listbox_ab_label" aria-required="false" class="c4" data-testid="textbox" @@ -622,11 +622,11 @@ input + .c2 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2x); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2x); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -707,7 +707,7 @@ input + .c2 { aria-describedby="uuid1_invalid" aria-expanded="true" aria-invalid="true" - aria-labelledby="uuid1_label" + aria-labelledby="uuid1_label uuid1_listbox_ab_label" aria-required="false" class="c6" data-testid="textbox" @@ -1051,11 +1051,11 @@ exports[`Dropdown list matches the snapshot (mobile) 1`] = ` display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2halfx); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2halfx); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -1113,7 +1113,7 @@ exports[`Dropdown list matches the snapshot (mobile) 1`] = ` aria-expanded="true" aria-invalid="false" aria-label="Select an option" - aria-labelledby="uuid1_label" + aria-labelledby="uuid1_label uuid1_listbox_ab_label" aria-required="false" class="c2" data-testid="textbox" @@ -1318,6 +1318,568 @@ exports[`Dropdown list matches the snapshot (mobile) 1`] = ` `; +exports[`Dropdown list matches the snapshot (multiselect) 1`] = ` +.c0 { + margin: 0 0 var(--spacing-3x); +} + +.c0 input, +.c0 select, +.c0 textarea { + border-color: #60666E; +} + +.c0:focus { + border-color: #006296; +} + +.c0 > :nth-child(0) { + margin-bottom: var(--spacing-half); +} + +.c5 { + background-color: #FFFFFF; + border: 1px solid #878F9A; + border-radius: var(--border-radius); + box-shadow: 0 0 0 1px #DBDEE1,0 10px 20px 0 rgb(0 0 0 / 0.2); + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + max-height: 160px; + overflow-y: auto; + padding: var(--spacing-half) 0; + position: relative; +} + +.c7 { + height: 100%; + list-style-type: none; + margin: 0; + padding: 0; + width: 100%; +} + +.c11 { + color: #FFFFFF; + height: 100%; + width: 100%; +} + +.c9 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: #FFFFFF; + border: 1px solid #60666E; + border-radius: var(--border-radius); + box-sizing: border-box; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + height: var(--size-1x); + -webkit-box-pack: center; + -webkit-justify-content: center; + -ms-flex-pack: center; + justify-content: center; + margin-right: var(--spacing-1x); + width: var(--size-1x); +} + +.c9:hover { + border: 1px solid #006296; +} + +.c9 > .c10 { + display: none; +} + +.c8 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + color: #000000; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + font-size: 0.875rem; + font-weight: var(--font-normal); + line-height: var(--size-1halfx); + min-height: var(--size-1halfx); + padding: var(--spacing-half) var(--spacing-2x); + position: relative; + padding-right: var(--spacing-1x); + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.c8:hover { + background-color: #DBDEE1; +} + +.c12 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-direction: column; + -ms-flex-direction: column; + flex-direction: column; +} + +.c1 { + position: relative; +} + +.c6 { + margin-top: 6px; + position: absolute; + width: 100%; +} + +.c2 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + background-color: #FFFFFF; + border: 1px solid #60666E; + border-radius: var(--border-radius); + box-sizing: border-box; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-box-pack: justify; + -webkit-justify-content: space-between; + -ms-flex-pack: justify; + justify-content: space-between; + min-height: var(--size-2x); + padding: 0 var(--spacing-1x) 0 0; + text-wrap: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + width: 100%; +} + +.c2:focus { + outline: none; +} + +.c2:focus { + outline: none; + border-color: #006296; + box-shadow: 0 0 0 2px #84C6EA; +} + +.c3 { + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex-wrap: wrap; + -ms-flex-wrap: wrap; + flex-wrap: wrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; +} + +.c4 { + -webkit-align-items: center; + -webkit-box-align: center; + -ms-flex-align: center; + align-items: center; + color: #60666E; + display: -webkit-box; + display: -webkit-flex; + display: -ms-flexbox; + display: flex; + -webkit-flex: none; + -ms-flex: none; + flex: none; + height: var(--size-1x); + margin-left: auto; + padding: var(--spacing-half); + width: var(--size-1x); +} + +
+
+ +
+ +
+
+ +
+
+`; + exports[`Dropdown list matches the snapshot 1`] = ` .c3 { color: #000000; @@ -1491,11 +2053,11 @@ input + .c2 { display: -webkit-flex; display: -ms-flexbox; display: flex; - height: var(--size-2x); -webkit-box-pack: justify; -webkit-justify-content: space-between; -ms-flex-pack: justify; justify-content: space-between; + min-height: var(--size-2x); padding: 0 var(--spacing-1x); text-wrap: none; -webkit-user-select: none; @@ -1566,7 +2128,7 @@ input + .c2 { aria-describedby="uuid1_hint" aria-expanded="true" aria-invalid="false" - aria-labelledby="uuid1_label" + aria-labelledby="uuid1_label uuid1_listbox_ab_label" aria-required="false" class="c5" data-testid="textbox" diff --git a/packages/react/src/components/dropdown-list/dropdown-list.tsx b/packages/react/src/components/dropdown-list/dropdown-list.tsx index f696999c9f..a0327c7378 100644 --- a/packages/react/src/components/dropdown-list/dropdown-list.tsx +++ b/packages/react/src/components/dropdown-list/dropdown-list.tsx @@ -5,6 +5,7 @@ import { useRef, useState, VoidFunctionComponent, + ReactNode, } from 'react'; import styled from 'styled-components'; import { useDataAttributes } from '../../hooks/use-data-attributes'; @@ -23,10 +24,14 @@ import { useListCursor } from '../../hooks/use-list-cursor'; import { useClickOutside } from '../../hooks/use-click-outside'; import { useListSearch } from '../../hooks/use-list-search'; import { sanitizeId } from '../../utils/dom'; +import { unique } from '../../utils/array'; +import { Tag } from '../tag/tag'; +import { findOptionsByValue } from '../listbox/listbox-option'; interface TextboxProps { $disabled?: boolean; $isMobile: boolean; + $isMultiselect?: boolean; theme: ResolvedTheme; $valid: boolean; value: string; @@ -65,9 +70,9 @@ const Textbox = styled.div` box-sizing: border-box; color: ${({ $disabled, theme }) => $disabled && theme.component['dropdown-list-input-disabled-text-color']}; display: flex; - height: ${({ $isMobile }) => ($isMobile ? 'var(--size-2halfx)' : 'var(--size-2x)')}; justify-content: space-between; - padding: 0 var(--spacing-1x); + min-height: ${({ $isMobile }) => ($isMobile ? 'var(--size-2halfx)' : 'var(--size-2x)')}; + padding: ${({ $isMultiselect }) => ($isMultiselect ? '0 var(--spacing-1x) 0 0' : '0 var(--spacing-1x)')}; text-wrap: none; user-select: none; width: 100%; @@ -82,6 +87,20 @@ const TextWrapper = styled.span` white-space: nowrap; `; +const TagWrapper = styled.div` + display: flex; + flex-wrap: wrap; + user-select: none; +`; + +const ListBoxTag = styled(Tag)` + margin: 2px; + + & + & { + margin-left: 2px; + } +`; + const Arrow = styled(Icon)<{ $disabled?: boolean }>` align-items: center; color: ${({ $disabled, theme }) => ($disabled ? theme.component['dropdown-list-arrow-disabled-color'] : theme.component['dropdown-list-arrow-color'])}; @@ -93,7 +112,14 @@ const Arrow = styled(Icon)<{ $disabled?: boolean }>` width: var(--size-1x); `; -export interface DropdownListProps { +type Value = string | string[]; + +export interface TagValue { + id?: string; + label: string; +} + +export interface DropdownListProps { /** * Aria label for the input (used when no visual label is present) */ @@ -106,7 +132,7 @@ export interface DropdownListProps { /** * The default selected option */ - defaultValue?: string; + defaultValue?: Value; /** * Disables input */ @@ -131,19 +157,20 @@ export interface DropdownListProps { /** * Set the selected value */ - value?: string; + value?: Value; hint?: string; + multiselect?: M; /** - * OnChange callback function, invoked when an option is selected + * OnChange callback function, invoked when options are selected */ - onChange?(option: DropdownListOption): void; + onChange?(option: M extends true ? DropdownListOption[] : DropdownListOption): void; } const optionPredicate: (option: DropdownListOption) => boolean = (option) => !option.disabled; const searchPropertyAccessor: (option: DropdownListOption) => string = (option) => option.label; -export const DropdownList: VoidFunctionComponent = ({ +export const DropdownList: VoidFunctionComponent> = ({ ariaLabel, className, defaultOpen = false, @@ -161,6 +188,7 @@ export const DropdownList: VoidFunctionComponent = ({ validationErrorMessage, value, hint, + multiselect, ...otherProps }) => { const { t } = useTranslation('dropdown-list'); @@ -173,28 +201,44 @@ export const DropdownList: VoidFunctionComponent = ({ const [open, setOpen] = useState(defaultOpen); - function findOptionByValue(searchValue?: string): DropdownListOption | undefined { - return options.find((option) => option.value === searchValue); - } - - function getDefaultOption(): DropdownListOption | undefined { - let defaultOption: DropdownListOption | undefined; + function getDefaultOptions(): DropdownListOption[] | undefined { + let defaultOptions: DropdownListOption[] | undefined; if (value !== undefined || defaultValue !== undefined) { - defaultOption = findOptionByValue(value ?? defaultValue); + defaultOptions = findOptionsByValue(options, value ?? defaultValue); } - if (defaultOption === undefined) { - defaultOption = options.find(optionPredicate); + if (defaultOptions === undefined && !multiselect) { + defaultOptions = [options.find(optionPredicate) ?? { value: '', label: '' }]; } - return defaultOption; + return defaultOptions; } - const [selectedOption, setSelectedOption] = useState( - () => getDefaultOption(), + const [selectedOptions, setSelectedOptions] = useState( + () => getDefaultOptions(), ); + function toggleOptionSelection(option: DropdownListOption, forceSelected?: boolean): void { + const newSelectedOptions = !selectedOptions?.includes(option) || forceSelected + ? unique([...selectedOptions ?? [], option]) + : selectedOptions?.filter((opt) => opt !== option); + setSelectedOptions(newSelectedOptions); + onChange?.(newSelectedOptions); + } + + function getLastSelectedOption( + optionsList: DropdownListOption[] | undefined, + ): DropdownListOption | undefined { + const last = (optionsList?.length ?? 0) - 1; + + if (last < 0) { + return undefined; + } + + return optionsList?.[last]; + } + const { selectedElement: focusedOption, setSelectedElement: setFocusedOption, @@ -204,16 +248,16 @@ export const DropdownList: VoidFunctionComponent = ({ selectLast: focusLastOption, } = useListCursor({ elements: options, - initialElement: selectedOption, + initialElement: getLastSelectedOption(selectedOptions), predicate: optionPredicate, }); - const [previousValue, setPreviousValue] = useState(value); + const [previousValue, setPreviousValue] = useState(value); if (value !== previousValue) { - const newOption = findOptionByValue(value); - setSelectedOption(newOption); - setFocusedOption(newOption); + const newOptions = findOptionsByValue(options, value); + setSelectedOptions(newOptions); + setFocusedOption(newOptions[0]); setPreviousValue(value); } @@ -222,10 +266,7 @@ export const DropdownList: VoidFunctionComponent = ({ return; } - if (!focusedOption && selectedOption) { - setFocusedOption(selectedOption); - } - + setFocusedOption(getLastSelectedOption(selectedOptions)); setOpen(true); } @@ -234,24 +275,24 @@ export const DropdownList: VoidFunctionComponent = ({ }, []); const selectOption: (option: DropdownListOption) => void = useCallback((option) => { - setSelectedOption(option); + setSelectedOptions([option]); onChange?.(option); - }, [onChange, setSelectedOption]); + }, [onChange, setSelectedOptions]); const handleClickOutside: () => void = useCallback(() => { if (open) { - if (focusedOption && focusedOption !== selectedOption) { + if (focusedOption && focusedOption !== selectedOptions?.[0] && !multiselect) { selectOption(focusedOption); } closeListbox(); } - }, [closeListbox, focusedOption, open, selectOption, selectedOption]); + }, [closeListbox, focusedOption, open, selectedOptions, multiselect, selectOption]); useClickOutside([textboxRef, listboxRef], handleClickOutside); function handleTextboxBlur(event: FocusEvent): void { if (open && event.relatedTarget !== listboxRef.current) { - if (focusedOption && focusedOption !== selectedOption) { + if (focusedOption && focusedOption !== selectedOptions?.[0] && !multiselect) { selectOption(focusedOption); } closeListbox(); @@ -268,23 +309,25 @@ export const DropdownList: VoidFunctionComponent = ({ function handleListboxOptionClick(option: DropdownListOption): void { if (optionPredicate(option)) { - if (option !== focusedOption) { - setFocusedOption(option); - } + if (multiselect) { + toggleOptionSelection(option); + } else { + if (option !== selectedOptions?.[0]) { + selectOption(option); + } - if (option !== selectedOption) { - selectOption(option); + closeListbox(); } - - closeListbox(); } } const handleFoundOption: (option?: DropdownListOption) => void = useCallback((option) => { - if (option) { + if (multiselect) { + setFocusedOption(getLastSelectedOption(selectedOptions)); + } else if (option) { setFocusedOption(option); } - }, [setFocusedOption]); + }, [setFocusedOption, multiselect, selectedOptions]); const { handleSearchInput, @@ -329,18 +372,31 @@ export const DropdownList: VoidFunctionComponent = ({ focusLastOption(); break; case 'Enter': - event.preventDefault(); + if (!multiselect) { + event.preventDefault(); + } + if (!open) { openListbox(); } else { - if (focusedOption && focusedOption !== selectedOption) { - selectOption(focusedOption); + if (focusedOption && focusedOption !== selectedOptions?.[0]) { + if (multiselect) { + toggleOptionSelection(focusedOption); + } else { + selectOption(focusedOption); + } + } + + if (!multiselect) { + closeListbox(); } - closeListbox(); } break; case ' ': - event.preventDefault(); + if (!multiselect) { + event.preventDefault(); + } + if (!open) { openListbox(); } @@ -362,11 +418,46 @@ export const DropdownList: VoidFunctionComponent = ({ } } + function handleTagRemove(tag: TagValue): void { + const removedOption = selectedOptions?.find((option) => option.value === tag.id); + + if (removedOption !== undefined) { + toggleOptionSelection(removedOption); + } + } + + const renderSelectedOptionsTags = (): ReactNode => selectedOptions?.map((option: DropdownListOption) => ( +