From e94ad90c45962a6cf6a7082c092c740e0dd52489 Mon Sep 17 00:00:00 2001 From: Alexandre Monjol Date: Mon, 30 Dec 2024 09:46:45 +0100 Subject: [PATCH] misc: migrate multiple combobox to tailwind (#1947) --- src/components/form/ComboBox/ComboBox.tsx | 6 +- .../form/ComboBox/ComboBoxPopperFactory.tsx | 17 +- src/components/form/ComboBox/ComboboxList.tsx | 2 +- .../MultipleComboBox/MultipleComboBox.tsx | 317 ++++++++---------- ...em.tsx => MultipleComboBoxItemWrapper.tsx} | 33 +- .../MultipleComboBox/MultipleComboBoxList.tsx | 94 +++--- .../MultipleComboBoxPopperFactory.tsx | 62 +--- .../MultipleComboBoxVirtualizedList.tsx | 28 +- src/components/form/MultipleComboBox/types.ts | 2 - 9 files changed, 226 insertions(+), 335 deletions(-) rename src/components/form/MultipleComboBox/{MultipleComboBoxItem.tsx => MultipleComboBoxItemWrapper.tsx} (75%) diff --git a/src/components/form/ComboBox/ComboBox.tsx b/src/components/form/ComboBox/ComboBox.tsx index b9433beb1..46e3caace 100644 --- a/src/components/form/ComboBox/ComboBox.tsx +++ b/src/components/form/ComboBox/ComboBox.tsx @@ -195,11 +195,7 @@ export const ComboBox = ({ // @ts-ignore { value, renderGroupHeader, virtualized } } - PopperComponent={ComboBoxPopperFactory({ - ...PopperProps, - grouped: !!(data || [])[0]?.group, - virtualized, - })} + PopperComponent={ComboBoxPopperFactory(PopperProps)} getOptionDisabled={(option) => !!option?.disabled} getOptionLabel={(option) => { const optionForString = diff --git a/src/components/form/ComboBox/ComboBoxPopperFactory.tsx b/src/components/form/ComboBox/ComboBoxPopperFactory.tsx index 9eecd8b64..be03d0012 100644 --- a/src/components/form/ComboBox/ComboBoxPopperFactory.tsx +++ b/src/components/form/ComboBox/ComboBoxPopperFactory.tsx @@ -1,19 +1,15 @@ import { Popper, PopperProps } from '@mui/material' -import { cx } from 'class-variance-authority' import { ReactNode } from 'react' import { theme } from '~/styles' import { ComboBoxProps } from './types' -type ComboBoxPopperFactoryArgs = Required>['PopperProps'] & { - grouped?: boolean - virtualized?: boolean -} +type ComboBoxPopperFactoryArgs = Required>['PopperProps'] // return a configured component with custom styles export const ComboBoxPopperFactory = - ({ placement, displayInDialog, grouped, virtualized }: ComboBoxPopperFactoryArgs = {}) => + ({ placement, displayInDialog }: ComboBoxPopperFactoryArgs = {}) => // eslint-disable-next-line react/display-name (props: PopperProps) => ( -
- {props?.children as ReactNode} -
+ <>{props?.children as ReactNode}
) diff --git a/src/components/form/ComboBox/ComboboxList.tsx b/src/components/form/ComboBox/ComboboxList.tsx index 92b0a57c6..36aa50756 100644 --- a/src/components/form/ComboBox/ComboboxList.tsx +++ b/src/components/form/ComboBox/ComboboxList.tsx @@ -22,7 +22,7 @@ interface ComboBoxVirtualizedListProps children: ReactNode } -const ComboboxListItem = ({ +export const ComboboxListItem = ({ children, virtualized, className, diff --git a/src/components/form/MultipleComboBox/MultipleComboBox.tsx b/src/components/form/MultipleComboBox/MultipleComboBox.tsx index e9eac6be5..24d3a30c3 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBox.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBox.tsx @@ -1,13 +1,12 @@ import { Autocomplete, createFilterOptions } from '@mui/material' import _sortBy from 'lodash/sortBy' import { useMemo, useState } from 'react' -import styled from 'styled-components' import { Chip, Icon } from '~/components/designSystem' import { useInternationalization } from '~/hooks/core/useInternationalization' import { tw } from '~/styles/utils' -import { MultipleComboBoxItem } from './MultipleComboBoxItem' +import { MultipleComboBoxItemWrapper } from './MultipleComboBoxItemWrapper' import { MultipleComboBoxList } from './MultipleComboBoxList' import { MultipleComboBoxPopperFactory } from './MultipleComboBoxPopperFactory' import { @@ -64,185 +63,165 @@ export const MultipleComboBox = ({ }) return ( - - { - if (typedValue.length === 0) { - if (open) setOpen(false) - } else { - if (!open) setOpen(true) - } + { + if (typedValue.length === 0) { + if (open) setOpen(false) + } else { + if (!open) setOpen(true) } - : undefined - } - onClose={() => setOpen(false)} - forcePopupIcon={forcePopupIcon} - disableCloseOnSelect={disableCloseOnSelect} - disableClearable={disableClearable} - disabled={disabled} - limitTags={limitTags || DEFAULT_LIMIT_TAGS} - options={data} - renderInput={(params) => ( - - )} - onChange={(_, newValue) => { - // Format all values to have the correct format - const formatedValues = newValue.map((val) => { - if (typeof val === 'string') { - return { value: val } } - return val - }) as (BasicMultipleComboBoxData | MultipleComboBoxDataGrouped)[] + : undefined + } + onClose={() => setOpen(false)} + forcePopupIcon={forcePopupIcon} + disableCloseOnSelect={disableCloseOnSelect} + disableClearable={disableClearable} + disabled={disabled} + limitTags={limitTags || DEFAULT_LIMIT_TAGS} + options={data} + renderInput={(params) => ( + + )} + onChange={(_, newValue) => { + // Format all values to have the correct format + const formatedValues = newValue.map((val) => { + if (typeof val === 'string') { + return { value: val } + } + return val + }) as (BasicMultipleComboBoxData | MultipleComboBoxDataGrouped)[] - // If more than one value, remove last element if value already exists - if (formatedValues.length > 1) { - const lastElementValue = formatedValues[formatedValues.length - 1].value + // If more than one value, remove last element if value already exists + if (formatedValues.length > 1) { + const lastElementValue = formatedValues[formatedValues.length - 1].value - for (let i = 0; i < formatedValues.length - 1; i++) { - const currentValue = formatedValues[i].value - const isNotLastElement = i !== formatedValues.length - 1 + for (let i = 0; i < formatedValues.length - 1; i++) { + const currentValue = formatedValues[i].value + const isNotLastElement = i !== formatedValues.length - 1 - if (isNotLastElement && currentValue === lastElementValue) { - formatedValues.length = formatedValues.length - 1 - break - } + if (isNotLastElement && currentValue === lastElementValue) { + formatedValues.length = formatedValues.length - 1 + break } } + } - onChange(formatedValues) - }} - value={value || undefined} - renderTags={(tagValues, getTagProps) => { - if (hideTags) { - return null - } - - return tagValues.map((option, index) => { - const tagOptions = getTagProps({ index }) - - return ( - - ) - }) - }} - componentsProps={{ - clearIndicator: { - className: tw('size-6 rounded-lg'), - }, - }} - clearIcon={} - popupIcon={} - noOptionsText={emptyText ?? translate('text_623b3acb8ee4e000ba87d082')} - clearOnBlur - freeSolo={freeSolo} - isOptionEqualToValue={ - !data.length && freeSolo - ? undefined - : (option, val) => { - return option?.value === val.value - } + onChange(formatedValues) + }} + value={value || undefined} + renderTags={(tagValues, getTagProps) => { + if (hideTags) { + return null } - renderOption={(props, option, state) => { + + return tagValues.map((option, index) => { + const tagOptions = getTagProps({ index }) + return ( - ) - }} - filterOptions={(options, params) => { - const filtered = filter(options, params) - - const { inputValue } = params - // Suggest the creation of a new value - const isExisting = options.some( - (option) => inputValue === option.value || inputValue === option.label, - ) - - if (inputValue !== '' && !isExisting && freeSolo) { - filtered.push({ - customValue: true, - value: inputValue, - label: translate('text_65ef30711cfd3e0083135de8', { value: inputValue }), - }) - } - - return filtered - }} - ListboxComponent={ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - MultipleComboBoxList as any - } - ListboxProps={ - // @ts-ignore - { value, renderGroupHeader, virtualized } + }) + }} + componentsProps={{ + clearIndicator: { + className: tw('size-6 rounded-lg'), + }, + }} + clearIcon={} + popupIcon={} + noOptionsText={emptyText ?? translate('text_623b3acb8ee4e000ba87d082')} + clearOnBlur + freeSolo={freeSolo} + isOptionEqualToValue={ + !data.length && freeSolo + ? undefined + : (option, val) => { + return option?.value === val.value + } + } + renderOption={(props, option, state) => { + return ( + + ) + }} + filterOptions={(options, params) => { + const filtered = filter(options, params) + + const { inputValue } = params + // Suggest the creation of a new value + const isExisting = options.some( + (option) => inputValue === option.value || inputValue === option.label, + ) + + if (inputValue !== '' && !isExisting && freeSolo) { + filtered.push({ + customValue: true, + value: inputValue, + label: translate('text_65ef30711cfd3e0083135de8', { value: inputValue }), + }) } - PopperComponent={MultipleComboBoxPopperFactory({ - ...PopperProps, - grouped: !!(data || [])[0]?.group, - virtualized, - })} - getOptionDisabled={(option) => !!option?.disabled} - getOptionLabel={(option) => { - const optionForString = - typeof option === 'string' ? data.find(({ value: val }) => val === option) : null - - return typeof option === 'string' - ? optionForString - ? optionForString.label || optionForString.value - : option - : option.label || option.value - }} - /> - + + return filtered + }} + ListboxComponent={ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + MultipleComboBoxList as any + } + ListboxProps={ + // @ts-ignore + { value, renderGroupHeader, virtualized } + } + PopperComponent={MultipleComboBoxPopperFactory(PopperProps)} + getOptionDisabled={(option) => !!option?.disabled} + getOptionLabel={(option) => { + const optionForString = + typeof option === 'string' ? data.find(({ value: val }) => val === option) : null + + return typeof option === 'string' + ? optionForString + ? optionForString.label || optionForString.value + : option + : option.label || option.value + }} + /> ) } -const Container = styled.div` - width: 100%; - - /* Prevent dropdown and clear button to overlap input */ - .MuiAutocomplete-inputRoot { - padding-right: 50px !important; - } - - /* Ensure the input does not shrink too much when items are selected */ - .MuiAutocomplete-input { - min-width: 80px !important; - } - - /* Fix the placement of the adornment elements */ - .MuiAutocomplete-endAdornment { - top: calc(50% - 12px); - } - - /* Make sure scursor is visible when overing svg */ - .MuiAutocomplete-popupIndicator, - .MuiAutocomplete-clearIndicator { - svg { - cursor: pointer; - } - } -` diff --git a/src/components/form/MultipleComboBox/MultipleComboBoxItem.tsx b/src/components/form/MultipleComboBox/MultipleComboBoxItemWrapper.tsx similarity index 75% rename from src/components/form/MultipleComboBox/MultipleComboBoxItem.tsx rename to src/components/form/MultipleComboBox/MultipleComboBoxItemWrapper.tsx index 4fde00fef..dcff94fa6 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBoxItem.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBoxItemWrapper.tsx @@ -1,10 +1,8 @@ import { cx } from 'class-variance-authority' import { Link } from 'react-router-dom' -import styled from 'styled-components' import { ConditionalWrapper } from '~/components/ConditionalWrapper' import { Icon, Typography } from '~/components/designSystem' -import { ITEM_HEIGHT, theme } from '~/styles' import { MultipleComboBoxData } from './types' @@ -20,7 +18,7 @@ interface MultipleComboBoxItemProps { addValueRedirectionUrl?: string } -export const MultipleComboBoxItem = ({ +export const MultipleComboBoxItemWrapper = ({ id, option: { customValue, value, label, description, disabled, labelNode }, selected, @@ -31,13 +29,15 @@ export const MultipleComboBoxItem = ({ const { className, ...allProps } = multipleComboBoxProps return ( - +
<>{children}} validWrapper={(children) => {children}} > - {/* @ts-ignore */} {customValue ? ( <> - + {labelNode ?? label} @@ -69,25 +69,6 @@ export const MultipleComboBoxItem = ({ )} - +
) } - -const ItemWrapper = styled.div` - display: flex; - align-items: center; - min-height: ${ITEM_HEIGHT}px; - - a { - &:focus, - &:active, - &:hover { - outline: none; - text-decoration: none; - } - } -` - -const AddCustomValueIcon = styled(Icon)` - margin-right: ${theme.spacing(4)}; -` diff --git a/src/components/form/MultipleComboBox/MultipleComboBoxList.tsx b/src/components/form/MultipleComboBox/MultipleComboBoxList.tsx index 47589e20b..10ca45628 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBoxList.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBoxList.tsx @@ -1,17 +1,17 @@ import _groupBy from 'lodash/groupBy' import { Children, ForwardedRef, forwardRef, ReactElement, ReactNode, useMemo } from 'react' -import styled, { css } from 'styled-components' import { Typography } from '~/components/designSystem' -import { theme } from '~/styles' +import { tw } from '~/styles/utils' import { - GROUP_HEADER_HEIGHT, - GROUP_ITEM_KEY, + MULTIPLE_GROUP_ITEM_KEY, MultipleComboBoxVirtualizedList, } from './MultipleComboBoxVirtualizedList' import { MultipleComboBoxData, MultipleComboBoxProps } from './types' +import { ComboboxListItem } from '../ComboBox/ComboboxList' + const randomKey = Math.round(Math.random() * 100000) interface MultipleComboBoxVirtualizedListProps @@ -37,9 +37,12 @@ export const MultipleComboBoxList = forwardRef( if (!isGrouped) { return Children.toArray( (children as ReactElement[]).map((item, i) => ( - + {item} - + )), ) } @@ -61,22 +64,35 @@ export const MultipleComboBoxList = forwardRef( ...acc, isGrouped ? [ - // If renderGroupHeader is provided, render the html, otherewise simply render the key - {(!!renderGroupHeader && (renderGroupHeader[key] as ReactNode)) || ( {key} )} - , + , ] : [], ...(groupedBy[key] as ReactElement[]).map((item, j) => ( - + {item} - + )), ] }, []), @@ -84,55 +100,21 @@ export const MultipleComboBoxList = forwardRef( }, [isGrouped, renderGroupHeader, children, propsToForward, virtualized]) return ( - +
{virtualized ? ( ) : ( htmlItems )} - +
) }, ) MultipleComboBoxList.displayName = 'MultipleComboBoxList' - -const Item = styled.div`` - -const Container = styled.div<{ $virtualized?: boolean }>` - max-height: inherit; - position: relative; - padding-bottom: 0; - box-sizing: border-box; - overflow: ${({ $virtualized }) => ($virtualized ? 'hidden' : 'auto')}; - - ${Item}:not(:last-child) { - margin: ${({ $virtualized }) => - $virtualized ? `0 ${theme.spacing(2)}` : `0 0 ${theme.spacing(1)}`}; - } -` - -const GroupHeader = styled.div<{ $isFirst?: boolean; $virtualized?: boolean }>` - height: ${GROUP_HEADER_HEIGHT}px; - display: flex; - width: inherit; - align-items: center; - padding: 0 ${theme.spacing(6)}; - background-color: ${theme.palette.grey[100]}; - box-sizing: border-box; - box-shadow: - ${theme.shadows[7]}, - 0px -1px 0px 0px ${theme.palette.divider}; - - ${({ $virtualized, $isFirst }) => - !$virtualized - ? css` - z-index: ${theme.zIndex.dialog + 2}; - position: sticky; - top: 0; - margin: ${$isFirst ? 0 : theme.spacing(2)} 0 ${theme.spacing(2)}; - ` - : css` - margin: ${$isFirst ? 0 : theme.spacing(1)} 0 ${theme.spacing(2)}; - `}; -` diff --git a/src/components/form/MultipleComboBox/MultipleComboBoxPopperFactory.tsx b/src/components/form/MultipleComboBox/MultipleComboBoxPopperFactory.tsx index 5ce8498a2..db6c9933a 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBoxPopperFactory.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBoxPopperFactory.tsx @@ -1,7 +1,5 @@ import { Popper, PopperProps } from '@mui/material' -import { cx } from 'class-variance-authority' import { ReactNode } from 'react' -import styled from 'styled-components' import { theme } from '~/styles' @@ -9,27 +7,20 @@ import { MultipleComboBoxProps } from './types' type MultipleComboBoxPopperFactoryArgs = Required< Pick ->['PopperProps'] & { - grouped?: boolean - virtualized?: boolean -} +>['PopperProps'] // return a configured component with custom styles export const MultipleComboBoxPopperFactory = - ({ - maxWidth, - minWidth, - placement, - displayInDialog, - grouped, - virtualized, - }: MultipleComboBoxPopperFactoryArgs = {}) => + ({ placement, displayInDialog }: MultipleComboBoxPopperFactoryArgs = {}) => // eslint-disable-next-line react/display-name (props: PopperProps) => ( - -
- {props?.children as ReactNode} -
-
+ <>{props?.children as ReactNode} +
) - -const StyledPopper = styled(Popper)<{ - $minWidth?: number - $maxWidth?: number - $displayInDialog?: boolean -}>` - min-width: ${({ $minWidth }) => $minWidth}px; - max-width: ${({ $maxWidth }) => ($maxWidth ? `${$maxWidth}px` : 'initial')}; - z-index: ${({ $displayInDialog }) => - $displayInDialog ? theme.zIndex.dialog + 1 : theme.zIndex.popper}; - - ${theme.breakpoints.down('md')} { - max-width: ${({ $minWidth }) => ($minWidth ? `${$minWidth}px` : 'initial')}; - } - - /* During TW migration, the padding should be removed, following Combobox implementation */ - .MuiAutocomplete-paper { - padding: ${theme.spacing(2)} 0; - } - - > *.multipleComboBox-popper--grouped .MuiAutocomplete-paper { - padding: ${theme.spacing(2)} 0; - } -` diff --git a/src/components/form/MultipleComboBox/MultipleComboBoxVirtualizedList.tsx b/src/components/form/MultipleComboBox/MultipleComboBoxVirtualizedList.tsx index 9f71ae127..688851a35 100644 --- a/src/components/form/MultipleComboBox/MultipleComboBoxVirtualizedList.tsx +++ b/src/components/form/MultipleComboBox/MultipleComboBoxVirtualizedList.tsx @@ -2,10 +2,11 @@ import { ReactElement, useEffect, useRef } from 'react' import { VariableSizeList } from 'react-window' import { ITEM_HEIGHT } from '~/styles' +import { tw } from '~/styles/utils' import { MultipleComboBoxProps } from './types' -export const GROUP_ITEM_KEY = 'multiple-comboBox-group-by' +export const MULTIPLE_GROUP_ITEM_KEY = 'multiple-comboBox-group-by' export const GROUP_HEADER_HEIGHT = 44 function useResetCache(itemCount: number) { @@ -26,17 +27,19 @@ type MultipleComboBoxVirtualizedListProps = { export const MultipleComboBoxVirtualizedList = (props: MultipleComboBoxVirtualizedListProps) => { const { elements, value } = props const itemCount = elements?.length - const hasDescription = elements.some( - (el) => (el.props?.children?.props?.option?.description as string)?.length > 0, - ) - const elementHeight = hasDescription ? ITEM_HEIGHT + 4 : ITEM_HEIGHT const getHeight = () => { + const hasAnyGroupHeader = elements.some((el) => + (el.key as string).includes(MULTIPLE_GROUP_ITEM_KEY), + ) + // recommended perf best practice if (itemCount > 5) { - return 5 * (elementHeight + 4) - 4 // Last item does not have 4px margin-bottom + return 5 * (ITEM_HEIGHT + 4) + 4 // Add 4px for margins + } else if (itemCount <= 2 && hasAnyGroupHeader) { + return itemCount * (ITEM_HEIGHT + 2) // Add 2px for margins } - return itemCount * (elementHeight + 4) - 4 // Last item does not have 4px margin-bottom + return itemCount * (ITEM_HEIGHT + 8) + 4 // Add 4px for margins } // reset the `VariableSizeList` cache if data gets updated @@ -62,6 +65,9 @@ export const MultipleComboBoxVirtualizedList = (props: MultipleComboBoxVirtualiz return ( 1, + })} itemData={elements} height={getHeight()} width="100%" @@ -69,10 +75,10 @@ export const MultipleComboBoxVirtualizedList = (props: MultipleComboBoxVirtualiz innerElementType="div" itemSize={(index) => { return index === itemCount - 1 - ? elementHeight - : ((elements[index].key as string) || '').includes(GROUP_ITEM_KEY) - ? GROUP_HEADER_HEIGHT + (index === 0 ? 8 : 12) - : elementHeight + 4 + ? ITEM_HEIGHT + : ((elements[index].key as string) || '').includes(MULTIPLE_GROUP_ITEM_KEY) + ? GROUP_HEADER_HEIGHT + (index === 0 ? 2 : 6) + : ITEM_HEIGHT + 8 }} overscanCount={5} itemCount={itemCount} diff --git a/src/components/form/MultipleComboBox/types.ts b/src/components/form/MultipleComboBox/types.ts index e86aa255a..ab4270717 100644 --- a/src/components/form/MultipleComboBox/types.ts +++ b/src/components/form/MultipleComboBox/types.ts @@ -37,8 +37,6 @@ interface BasicMultipleComboBoxProps limitTags?: number disableClearable?: boolean PopperProps?: Pick & { - minWidth?: number - maxWidth?: number displayInDialog?: boolean offset?: string }