diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b12d2369..50240eb05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Not released +- Autocomplete creatable: improved API to cover more use cases [#881](https://github.com/CartoDB/carto-react/pull/881) +- Fix LegendProportion radius scale [#877](https://github.com/CartoDB/carto-react/pull/877) - Fix time zone handling in week counts, separate getMonday and getUTCMonday utilities [#879](https://github.com/CartoDB/carto-react/pull/879) ## 3.0.0 @@ -12,7 +14,7 @@ ### 3.0.0-alpha.11 (2024-06-12) -- Add creatable functionality to Autocomplete & MenuItem fixed [#828](https://github.com/CartoDB/carto-react/pull/828) +- Add creatable functionality to Autocomplete & MenuItem fixed [#873](https://github.com/CartoDB/carto-react/pull/873) - Table component: Added selected row and with checkbox example in Storybook [#876](https://github.com/CartoDB/carto-react/pull/876) ### 3.0.0-alpha.10 (2024-06-03) diff --git a/packages/react-ui/src/components/molecules/Autocomplete.d.ts b/packages/react-ui/src/components/molecules/Autocomplete.d.ts index 2989adb7c..f58fa9f1c 100644 --- a/packages/react-ui/src/components/molecules/Autocomplete.d.ts +++ b/packages/react-ui/src/components/molecules/Autocomplete.d.ts @@ -10,7 +10,8 @@ export type AutocompleteProps< ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent'] > = MuiAutocompleteProps & { creatable?: boolean; - newItemTitle?: React.ReactNode | string; + newItemLabel?: string | ((value: string) => React.ReactNode | string); + newItemIcon?: React.ReactNode; }; declare const Autocomplete: < diff --git a/packages/react-ui/src/components/molecules/Autocomplete.js b/packages/react-ui/src/components/molecules/Autocomplete.js index f1a6ed081..d27e310fe 100644 --- a/packages/react-ui/src/components/molecules/Autocomplete.js +++ b/packages/react-ui/src/components/molecules/Autocomplete.js @@ -11,6 +11,7 @@ import { import { AddCircleOutlineOutlined } from '@mui/icons-material'; import MenuItem from './MenuItem'; import useImperativeIntl from '../../hooks/useImperativeIntl'; +import Typography from '../atoms/Typography'; const filter = createFilterOptions(); @@ -18,7 +19,8 @@ const Autocomplete = forwardRef( ( { creatable, - newItemTitle, + newItemLabel = 'c4r.form.add', + newItemIcon, freeSolo, renderOption, forcePopupIcon, @@ -33,7 +35,7 @@ const Autocomplete = forwardRef( const intl = useIntl(); const intlConfig = useImperativeIntl(intl); - const creatableOptions = (options, params) => { + const creatableFilterOptions = (options, params) => { const filtered = filter(options, params); const { inputValue } = params; @@ -43,8 +45,9 @@ const Autocomplete = forwardRef( filtered.push({ inputValue, title: - newItemTitle || - `${intlConfig.formatMessage({ id: 'c4r.form.add' })} "${inputValue}"` + typeof newItemLabel === 'function' + ? newItemLabel(inputValue) + : `${intlConfig.formatMessage({ id: newItemLabel })} "${inputValue}"` }); } @@ -66,22 +69,46 @@ const Autocomplete = forwardRef( const creatableRenderOption = (props, option) => ( - {option.inputValue && } - - {option.inputValue && ( - - - - )} - {option.title} - + {option.divider ? ( + + ) : ( + <> + {option.inputValue && } + + {option.inputValue && ( + {newItemIcon || } + )} + {option.startAdornment && !option.inputValue && ( + {option.startAdornment} + )} + + {option.alternativeTitle || option.title} + {option.secondaryText && ( + + {option.secondaryText} + + )} + + {option.endAdornment} + + + )} ); return ( !['subtitle', 'destructive', 'extended', 'iconColor', 'fixed'].includes(prop) -})(({ subtitle, destructive, extended, iconColor, fixed, theme }) => ({ +})(({ dense, subtitle, destructive, extended, iconColor, fixed, theme }) => ({ ...(subtitle && { pointerEvents: 'none', columnGap: 0, @@ -13,14 +13,20 @@ const StyledMenuItem = styled(MuiMenuItem, { fontWeight: 500, color: theme.palette.text.secondary, + '.MuiListItemText-root .MuiTypography-root': { + ...theme.typography.caption, + fontWeight: 500, + color: theme.palette.text.secondary + }, + '&.MuiMenuItem-root': { minHeight: theme.spacing(3), paddingTop: 0, paddingBottom: 0, + marginTop: theme.spacing(1), '&:not(:first-of-type)': { minHeight: theme.spacing(5), - marginTop: theme.spacing(1), paddingTop: theme.spacing(1), borderTop: `1px solid ${theme.palette.divider}` } @@ -33,6 +39,9 @@ const StyledMenuItem = styled(MuiMenuItem, { }, '&.Mui-selected .MuiListItemIcon-root svg path': { fill: theme.palette.primary.main + }, + '.MuiAutocomplete-listbox &[aria-selected="true"] svg path': { + fill: theme.palette.primary.main } }), ...(destructive && { @@ -77,7 +86,7 @@ const StyledMenuItem = styled(MuiMenuItem, { } }), ...(extended && { - '&.MuiMenuItem-root': { + '&.MuiButtonBase-root.MuiMenuItem-root': { minHeight: theme.spacing(6) } }), @@ -94,12 +103,26 @@ const StyledMenuItem = styled(MuiMenuItem, { padding: theme.spacing(0.5, 1.5), backgroundColor: theme.palette.background.paper, borderBottom: `1px solid ${theme.palette.divider}` + }, + '.MuiAutocomplete-listbox &.MuiAutocomplete-option:first-of-type': { + minHeight: theme.spacing(6), + marginTop: 0, + + '&:hover': { + backgroundColor: theme.palette.background.paper + } } }), ...(!fixed && { '.MuiList-root &:first-of-type': { marginTop: theme.spacing(1) } + }), + ...(dense && { + '&.MuiButtonBase-root.MuiMenuItem-root': { + minHeight: theme.spacing(3), + padding: theme.spacing(0.25, 1.5) + } }) })); diff --git a/packages/react-ui/src/theme/sections/components/forms.js b/packages/react-ui/src/theme/sections/components/forms.js index 00388f18d..6f1876cc2 100644 --- a/packages/react-ui/src/theme/sections/components/forms.js +++ b/packages/react-ui/src/theme/sections/components/forms.js @@ -715,10 +715,19 @@ export const formsOverrides = { color: theme.palette.primary.main, backgroundColor: theme.palette.primary.background, + '.MuiTypography-root': { + color: theme.palette.primary.main + }, + '.MuiTypography-caption': { + color: theme.palette.text.secondary + }, '&.Mui-focused:hover': { backgroundColor: theme.palette.action.hover } }, + '&:first-of-type': { + marginTop: theme.spacing(1) + }, ...(ownerState.size === 'small' && { padding: theme.spacing(0.5, 1.5) @@ -737,11 +746,16 @@ export const formsOverrides = { }), listbox: ({ ownerState, theme }) => ({ + paddingTop: 0, + '.MuiDivider-root': { display: 'none' }, '.MuiButtonBase-root + .MuiDivider-root': { display: 'block' + }, + '.MuiMenuItem-root:first-of-type': { + marginTop: theme.spacing(1) } }) } diff --git a/packages/react-ui/storybook/stories/molecules/Autocomplete.stories.js b/packages/react-ui/storybook/stories/molecules/Autocomplete.stories.js index 35a21172d..5ac01e9e9 100644 --- a/packages/react-ui/storybook/stories/molecules/Autocomplete.stories.js +++ b/packages/react-ui/storybook/stories/molecules/Autocomplete.stories.js @@ -1,6 +1,7 @@ import React, { useState } from 'react'; import { IntlProvider } from 'react-intl'; import { + Chip, Grid, InputAdornment, ListItemIcon, @@ -102,7 +103,7 @@ const options = { type: 'text' } }, - newItemTitle: { + newItemLabel: { control: { type: 'text' } @@ -121,36 +122,79 @@ const options = { export default options; const top100Films = [ - { title: 'The Shawshank Redemption', year: 1994, icon: }, - { title: 'The Godfather', year: 1972, icon: }, - { title: 'The Godfather: Part II', year: 1974, icon: }, - { title: 'The Dark Knight', year: 2008, icon: }, - { title: '12 Angry Men', year: 1957, icon: }, - { title: "Schindler's List", year: 1993, icon: }, - { title: 'Pulp Fiction', year: 1994, icon: }, + { + title: 'The Shawshank Redemption', + year: 1994, + startAdornment: , + fixed: true + }, + { + title: 'Extended item', + secondaryText: 'Secondary text', + year: 1972, + startAdornment: , + extended: true + }, + { + title: 'The Godfather: Part II', + year: 1974, + startAdornment: , + dense: true + }, + { + title: 'The Dark Knight', + alternativeTitle: 'Movie: The Dark Knight', + year: 2008, + startAdornment: + }, + { + title: '12 Angry Men', + year: 1957, + startAdornment: , + iconColor: 'default' + }, + { + title: "Schindler's List", + year: 1993, + startAdornment: , + endAdornment: + }, + { title: 'Subtitle', subtitle: true }, + { + title: 'Pulp Fiction', + year: 1994, + startAdornment: , + disabled: true + }, { title: 'The Lord of the Rings: The Return of the King', year: 2003, - icon: + startAdornment: + }, + { + title: 'The Good, the Bad and the Ugly', + year: 1966, + startAdornment: }, - { title: 'The Good, the Bad and the Ugly', year: 1966, icon: }, - { title: 'Fight Club', year: 1999, icon: }, + { title: 'Fight Club', year: 1999, startAdornment: }, { title: 'The Lord of the Rings: The Fellowship of the Ring', year: 2001, - icon: + startAdornment: }, { title: 'Star Wars: Episode V - The Empire Strikes Back', year: 1980, - icon: + startAdornment: }, - { title: 'Forrest Gump', year: 1994, icon: }, - { title: 'Inception', year: 2010, icon: }, + { title: 'Forrest Gump', year: 1994, startAdornment: }, + { title: 'Inception', year: 2010, startAdornment: }, + { title: 'divider', divider: true }, { title: 'The Lord of the Rings: The Two Towers', year: 2002, - icon: + startAdornment: , + destructive: true } ]; @@ -910,7 +954,9 @@ const RenderOptionTemplate = ({ renderInput={(params) => { if (selectedOption) { params.InputProps.startAdornment = ( - {selectedOption.icon} + + {selectedOption.startAdornment} + ); } return ( @@ -929,9 +975,22 @@ const RenderOptionTemplate = ({ }} size={size} renderOption={(props, option) => ( - - {option.icon} - {option.title} + + {option.startAdornment} + + {option.title} + + {option.secondaryText} + + + {option.endAdornment} )} /> @@ -955,7 +1014,7 @@ const CreatableTemplate = ({ if (newOption.inputValue) { const newFilm = { title: newOption.inputValue, - icon: + startAdornment: }; setCreatableTop100Films((prev) => [...prev, newFilm]); } @@ -967,7 +1026,6 @@ const CreatableTemplate = ({ {...args} creatable options={creatableTop100Films} - getOptionLabel={(option) => option.title} onChange={(event, newValue) => { if (newValue && newValue.inputValue) { handleAddOption(newValue); @@ -992,6 +1050,79 @@ const CreatableTemplate = ({ ); }; +const CreatableWithPrefixAndSuffixTemplate = ({ + label, + variant, + placeholder, + helperText, + error, + size, + required, + ...args +}) => { + const [creatableTop100Films, setCreatableTop100Films] = useState(top100Films); + const [selectedOption, setSelectedOption] = useState(null); + + const handleAddOption = (newOption) => { + if (newOption.inputValue) { + const newFilm = { + title: newOption.inputValue, + startAdornment: + }; + setCreatableTop100Films((prev) => [...prev, newFilm]); + } + }; + + return ( + + { + if (newValue && newValue.inputValue) { + handleAddOption(newValue); + } + setSelectedOption(newValue); + }} + renderInput={(params) => { + if (selectedOption) { + params.InputProps.startAdornment = ( + + {selectedOption.startAdornment} + + ); + params.InputProps.endAdornment = ( + <> + + {selectedOption.endAdornment} + + + {params.InputProps.endAdornment} + + + ); + } + return ( + + ); + }} + size={size} + /> + + ); +}; + const FreeSoloTemplate = ({ label, variant, @@ -1122,5 +1253,15 @@ CustomRenderOption.args = { ...commonArgs }; export const Creatable = CreatableTemplate.bind({}); Creatable.args = { ...commonArgs }; +export const CreatableCustomNewOption = CreatableTemplate.bind({}); +CreatableCustomNewOption.args = { + ...commonArgs, + newItemLabel: (value) => `Add this '${value}' new item`, + newItemIcon: +}; + +export const CreatableWithPrefixAndSuffix = CreatableWithPrefixAndSuffixTemplate.bind({}); +CreatableWithPrefixAndSuffix.args = { ...commonArgs }; + export const FreeSolo = FreeSoloTemplate.bind({}); FreeSolo.args = { ...commonArgs };