From f02b1d36a31438670dc574d5d278961b55c1267b Mon Sep 17 00:00:00 2001 From: Esen Dzhailobaev Date: Wed, 10 Jan 2024 17:56:00 +0600 Subject: [PATCH] feat(singleselect): add new component SingleSelect (#511) * temp * basic version * incremental in tsconfig * TBA * new build * label variations * error handling * "inverted" type for inline label * use flex instead of hstack for label * support for additionalTexts and components * label placement is not inverted by default * full-width label * label enhancements * turn off default invalid * label no longer 100% * labelPlacement -> invertLabelPosition * remove unused props * portal into document.body * props.selectProps -> props.selectProps? * fix a dumb bug * add menuPlacement prop * expose defaultValue prop * conditional onChange value * remove defaultValue prop support, not needed * unite singleselect and editable/single * move select to portal again * correct conditional types * add isDisabled and isLoading * fix(singleselect): remove customFilter support for SingleSelect * Remove unused * Remove unused --------- Co-authored-by: Ehsan Heydari --- src/components/select/stories.tsx | 42 ++- src/components/single-select/index.tsx | 313 ++++++++++++++++++ src/components/single-select/label.tsx | 163 +++++++++ src/components/single-select/select.styles.ts | 84 +++++ src/components/single-select/stories.tsx | 126 +++++++ src/index.ts | 1 + tsconfig.json | 9 +- 7 files changed, 710 insertions(+), 28 deletions(-) create mode 100644 src/components/single-select/index.tsx create mode 100644 src/components/single-select/label.tsx create mode 100644 src/components/single-select/select.styles.ts create mode 100644 src/components/single-select/stories.tsx diff --git a/src/components/select/stories.tsx b/src/components/select/stories.tsx index 35ec1b99b..b7c5d2a38 100644 --- a/src/components/select/stories.tsx +++ b/src/components/select/stories.tsx @@ -4,7 +4,7 @@ import { StoryObj, Meta } from '@storybook/react'; import { Box } from 'rebass'; import Select from './index'; -import { Popup, RadioGroup, Value } from '../../index'; +import { RadioGroup, Value } from '../../index'; import Labeling from '../typography/labeling'; const meta: Meta = { @@ -149,27 +149,25 @@ export const Default: StoryObj = { }; return ( - {}}> - - e.stopPropagation()} + options={['all', 'matching feature only']} + /> + } + /> + ); }, }; diff --git a/src/components/single-select/index.tsx b/src/components/single-select/index.tsx new file mode 100644 index 000000000..a09fe6cfd --- /dev/null +++ b/src/components/single-select/index.tsx @@ -0,0 +1,313 @@ +import { + Box, + BoxProps, + Flex, + FormControl, + FormErrorMessage, + FormLabel, + HStack, + Text, +} from '@chakra-ui/react'; +import { + CreatableSelect, + OptionBase, + Props as PublicBaseSelectProps, + Select, + chakraComponents, + type SingleValue as ISingleValue, +} from 'chakra-react-select'; +import * as R from 'ramda'; +import { ReactNode } from 'react'; +import { Intents } from '../intents'; +import Label from '../label'; +import Labeling from '../typography/labeling'; + +export interface SingleSelectOption extends OptionBase { + label: string; + value: string | undefined; + additionalText?: string; + additionalComponent?: React.ReactNode; +} + +type ParentProps = Pick; +type CleanBoxProps = Omit< + BoxProps, + 'onChange' | 'children' | 'className' | 'defaultValue' +>; + +type Conditionals = + | { + isClearable: true; + onChange: (value: string | undefined) => void; + } + | { + isClearable: false; + onChange: (value: string) => void; + } + | { + isClearable?: undefined; + onChange: (value: string) => void; + }; + +export type Props = ParentProps & + CleanBoxProps & + Conditionals & { + editable?: boolean; + value: SingleSelectOption['value']; + options: SingleSelectOption[] | string[]; + placeholder?: string; + label?: string; + disabled?: boolean; + width?: string | number; + maxListHeight?: string; + labelAction?: React.ReactNode; + /** @deprecated not used meaningfully anywhere */ + listWidth?: string | number; // deprecate + variant?: 'primary' | 'white'; + noDataMessage?: string; + isClearable?: boolean; // just show X or not + labelPosition?: 'side' | 'inline' | 'outside'; + invertLabelPosition?: boolean; + isInvalid?: boolean; + isDisabled?: boolean; + isLoading?: boolean; + errorMessage?: ReactNode; + + // out of scope rn + intent?: Intents; + bottomActionText?: string; + bottomActionHandler?: () => void; + }; + +const hasStringOptions = ( + it: (string | SingleSelectOption)[], +): it is string[] => typeof it[0] === 'string'; + +export const SingleSelect = ({ + options: rawOptions, + value, + onChange, + placeholder, + disabled, + label, + labelAction, + width, + maxListHeight, + variant, + noDataMessage, + editable = false, + isClearable = false, + labelPosition = 'outside', + invertLabelPosition = false, + isInvalid, + isDisabled, + isLoading, + errorMessage = '', + menuPlacement, + ...props +}: Props) => { + const options: SingleSelectOption[] = hasStringOptions(rawOptions) + ? rawOptions.map((it) => ({ value: it, label: it })) + : rawOptions; + + const handleChange = (selectedOption: ISingleValue) => { + onChange(selectedOption?.value as string); + }; + + const labelProps: Pick< + Props, + 'invertLabelPosition' | 'labelPosition' | 'label' + > = { + labelPosition, + invertLabelPosition, + label, + }; + + let flexDirection: BoxProps['flexDirection'] = 'column'; + if (labelPosition === 'side') { + flexDirection = `row${invertLabelPosition ? '-reverse' : ''}`; + } + + const Component = editable ? CreatableSelect : Select; + + const propsForEditable = editable + ? { + formatCreateLabel: CreateLabel, + } + : {}; + + return ( + + {['outside', 'side'].includes(labelPosition) && label && ( + + + )} + + isMulti={false} + variant={variant} + tagVariant="solid" + useBasicStyles + isClearable={isClearable} + size="sm" + openMenuOnFocus // needed for accessibility, e.g. trigger on a label click + options={options} + placeholder={ + label && labelPosition === 'inline' + ? `${label} ${placeholder ?? ''}` + : placeholder + } + value={options.find((it) => it.value === value)} + onChange={handleChange} + selectedOptionColorScheme="gray" + closeMenuOnSelect + noOptionsMessage={R.always( + isNotEmptyAndNotUndefined(noDataMessage) ? noDataMessage! : '— • —', + )} + menuPortalTarget={document.querySelector('.chakra-portal') as any} + menuShouldBlockScroll + styles={{ + menuPortal: (provided) => ({ ...provided, zIndex: 2000 }), + }} + menuPlacement={menuPlacement ?? 'auto'} + chakraStyles={chakraStyles({ width, maxListHeight, variant })} + components={ + { + MenuList, + SingleValue, + Option, + } as any + } + isLoading={isLoading} + // Additional customization can be added here + // {...props} + {...labelProps} + {...propsForEditable} + /> + {errorMessage && ( + + {errorMessage} + + )} + + ); +}; + +// + +const chakraStyles = ({ + width, + maxListHeight, + variant, +}: Pick) => ({ + container: R.mergeLeft({ + width, + }), + menuList: R.mergeLeft({ + my: 1, + py: 0, + maxHeight: maxListHeight, + width: 'max-content', + }), + multiValue: R.mergeLeft({ + bg: variant === 'white' ? 'grayShade3' : 'background', + }), + placeholder: R.mergeLeft({ + fontSize: '12px', + color: 'gray', + }), + input: R.mergeLeft({ + fontSize: '12px', + }), + option: R.mergeLeft({ + fontSize: '12px', + }), + noOptionsMessage: R.mergeLeft({ + fontSize: '12px', + }), + singleValue: R.mergeLeft({ + fontSize: '12px', + }), + menu: R.mergeLeft({ + my: 0, + py: 0, + }), +}); + +const SingleValue = ({ children, ...props }: any) => { + return ( + + + {props.selectProps?.labelPosition === 'inline' && ( + + {props.selectProps?.label} + + )} + + {children} {/* This renders the options */} + + + + ); +}; + +const MenuList = ({ children, ...props }: any) => { + return ( + + {children} {/* This renders the options */} + + ); +}; + +const Option = ({ children, ...props }: any) => { + return ( + + + {children} {/* This renders the options */} + {props.data.additionalText} + {props.data.additionalComponent} + + + ); +}; + +const isNotEmptyAndNotUndefined = R.both( + R.complement(R.isNil), + R.complement(R.isEmpty), +); + +const CreateLabel = (text: string) => ( + + + add + + {text} + +); diff --git a/src/components/single-select/label.tsx b/src/components/single-select/label.tsx new file mode 100644 index 000000000..df592af54 --- /dev/null +++ b/src/components/single-select/label.tsx @@ -0,0 +1,163 @@ +import { forwardRef, useMemo } from 'react'; +import { Box, BoxProps, Flex } from 'rebass'; +import { GetIcon, IconName } from '../icon'; + +// Icons +import { Intents } from '../intents'; +// Components +import Labeling from '../typography/labeling'; +// Styles +import { valueStyles, getLabelStyles, deletabledStyles } from './select.styles'; + +export interface SelectLabelProps extends Omit { + variant: 'primary' | 'white' | 'disabled'; + placeholder: string; + value: string[]; + options: string[]; + children: React.ReactNode; + isMulti?: boolean; + noDataMessage?: string; + hasPlaceholder: boolean; + intent: Intents; + additionalTexts?: string[]; + needSecondaryText: boolean; + needSwap: boolean; + deletabled?: boolean; + onChange: (value: string[]) => void; +} + +const getAdditionalText = ( + value: string[], + options: string[], + additionalTexts: string[], +) => { + const index = options.indexOf(value[0]); + + if (index > -1) { + return additionalTexts[index]; + } + + return ''; +}; + +const getLabelText = ( + value: string[], + options: string[], + isMulti?: boolean, +) => { + if (!options.length && !value.length) { + return ''; + } + + if (value[0] === 'any' && value.length === 1) { + return 'any'; + } + + return value.length === options.length && isMulti ? 'all' : value.join(', '); +}; + +const SelectLabel = forwardRef( + ( + { + variant, + placeholder, + value, + children, + options, + onChange, + isMulti, + noDataMessage, + deletabled, + intent, + additionalTexts, + hasPlaceholder, + needSecondaryText, + needSwap = false, + ...props + }: SelectLabelProps, + ref, + ) => { + const content = useMemo(() => { + if (!options.length && !value.length) { + return noDataMessage; + } + + if (!options.length) { + return noDataMessage; + } + + if (!value.length) { + return placeholder; + } + + if (value.length && hasPlaceholder) { + return placeholder; + } + + return hasPlaceholder ? placeholder : ''; + }, [value, options, hasPlaceholder, placeholder, noDataMessage]); + + return ( + + {needSwap ? ( + + + + {getLabelText(value, options, isMulti)} + + + + {content} + + + ) : ( + + + {content} + + + + {getLabelText(value, options, isMulti)} + + {!!additionalTexts?.length && needSecondaryText && ( + + {getAdditionalText(value, options, additionalTexts)} + + )} + + + )} + + {deletabled && ( + { + e.stopPropagation(); + + onChange([]); + }} + sx={deletabledStyles} + ml="auto" + > + + + )} + + + {children} + + ); + }, +); +export default SelectLabel; diff --git a/src/components/single-select/select.styles.ts b/src/components/single-select/select.styles.ts new file mode 100644 index 000000000..c715c3472 --- /dev/null +++ b/src/components/single-select/select.styles.ts @@ -0,0 +1,84 @@ +import { SxStyleProp } from 'rebass'; +import { ITheme } from '../../theme/types'; +import { Intents } from '../intents'; + +const getIntentColor = + (intent: Intents) => + ({ inputIntents }: ITheme) => + inputIntents[intent] ?? 'transparent'; + +export const listStyles = ( + parentHeight: number, + appendToBody: boolean, +): SxStyleProp => ({ + position: 'absolute', + zIndex: 200, + left: 0, + top: `${parentHeight}px`, + ...(appendToBody && { + marginTop: '2px !important', + marginLeft: '1px !important', + }), +}); + +export const bottomActionStyles = { + p: '10px', + borderTopWidth: '1px', + borderTopStyle: 'solid', + borderTopColor: 'grayShade2', + + ':hover': { + bg: 'grayShade3', + }, +}; + +export const valueStyles = { + flexGrow: 2, + overflow: 'hidden', + whiteSpace: 'nowrap', + textOverflow: 'ellipsis', +}; + +export const deletabledStyles = { + borderStyle: 'solid', + borderWidth: '1px', + borderColor: 'transparent', + cursor: 'pointer', + transition: 'all .25s ease', + + ':hover': { + borderColor: 'grayShade2', + }, + svg: { + width: '16px', + height: '16px', + }, +}; + +export const getLabelStyles = (intent: Intents, deletabled?: boolean) => ({ + position: 'relative', + + borderColor: getIntentColor(intent), + + outline: 'none', + + height: '32px', + + display: 'flex', + alignItems: 'center', + flexGrow: 1, + + borderWidth: '1px', + borderStyle: 'solid', + + pl: '5px', + pr: '5px', + + transition: 'all 0.25s ease', + + ...(!deletabled && { + svg: { + minWidth: '8px', + }, + }), +}); diff --git a/src/components/single-select/stories.tsx b/src/components/single-select/stories.tsx new file mode 100644 index 000000000..c8e13df26 --- /dev/null +++ b/src/components/single-select/stories.tsx @@ -0,0 +1,126 @@ +import { useState } from 'react'; +import { StoryObj, Meta } from '@storybook/react'; + +import { Box } from 'rebass'; +import { SingleSelect, SingleSelectOption } from './index'; +import { Value } from '../../index'; +import Labeling from '../typography/labeling'; + +const meta: Meta = { + title: 'SingleSelect', + component: SingleSelect, + argTypes: { + value: { + description: 'Array of strings', + }, + editable: { + description: 'Allow to add new values', + control: { type: 'boolean' }, + }, + options: { + description: 'Array of strings', + control: { type: 'array' }, + }, + placeholder: { + control: { type: 'text' }, + }, + noDataMessage: { + control: { type: 'text' }, + }, + label: { + control: { type: 'text' }, + }, + labelPosition: { + control: { + type: 'select', + options: ['side', 'inline', 'outside'], + }, + }, + isClearable: { + control: { type: 'boolean' }, + defaultValue: { description: 'deletabled (need to select a value)' }, + }, + intent: { + control: { + type: 'select', + options: ['default', 'error'], + }, + description: 'Select intent (error border)', + }, + disabled: { + control: { type: 'boolean' }, + }, + width: { + control: { type: 'text' }, + defaultValue: { description: 'auto' }, + }, + labelAction: { + description: 'React Component', + control: {}, + }, + listWidth: { + control: { type: 'text' }, + defaultValue: { description: 'max-content' }, + }, + variant: { + control: { type: 'select', options: ['primary', 'white'] }, + defaultValue: { description: 'primary' }, + }, + }, +}; +export default meta; + +const customOptions = [ + { + value: '1', + label: 'One', + additionalText: 'add_one', + additionalComponent: text, + }, + { + value: '2', + label: 'Two', + additionalComponent: ( + + text + + ), + }, + { value: '3', label: 'Three', additionalText: 'add_three' }, + { + value: '4', + label: 'Four', + additionalText: 'add_four', + additionalComponent: text, + }, +]; + +export const Default: StoryObj = { + args: { + placeholder: 'placeholder', + editable: false, + label: 'Label', + variant: 'primary', + labelPosition: 'outside', + noDataMessage: 'no labels', + isClearable: false, + }, + render: (props) => { + const [value, setValue] = useState(); + + const handleChange = (data: SingleSelectOption['value']) => { + setValue(data); + }; + return ( + + + + ); + }, +}; diff --git a/src/index.ts b/src/index.ts index bf9a40e03..ca81347cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -117,6 +117,7 @@ export * from './components/project-badge'; export * from './components/freshness-bar'; export * from './components/range-slider'; export * from './components/slider'; +export * from './components/single-select'; export * from './theme/useColorMode'; diff --git a/tsconfig.json b/tsconfig.json index d9c16dd2d..89eeadea6 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -22,12 +22,9 @@ "forceConsistentCasingInFileNames": true, "resolveJsonModule": true, "isolatedModules": true, - "noEmit": true + "noEmit": true, + "incremental": true }, - "include": [ - "src", - "./vite.config.ts", - "@types/testing-library__jest-dom" - ], + "include": ["src", "./vite.config.ts", "@types/testing-library__jest-dom"], "exclude": ["node_modules", "dist"] }