Skip to content

Commit

Permalink
feat(singleselect): add new component SingleSelect (#511)
Browse files Browse the repository at this point in the history
* 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 <ehsan.heydari.yk@gmail.com>
  • Loading branch information
snqb and ehsan-github authored Jan 10, 2024
1 parent 9473e95 commit f02b1d3
Show file tree
Hide file tree
Showing 7 changed files with 710 additions and 28 deletions.
42 changes: 20 additions & 22 deletions src/components/select/stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Select> = {
Expand Down Expand Up @@ -149,27 +149,25 @@ export const Default: StoryObj<typeof Select> = {
};

return (
<Popup isOpen onClose={() => {}}>
<Box width="300px" height="60px" m="20px">
<Select
{...props}
value={value}
maxListHeight="initial"
options={customOptions}
onChange={handleChange}
customFilter={
<RadioGroup
ml="10px"
value={selected}
flexDirection="row"
onChange={handleChangeFilter}
onClick={(e) => e.stopPropagation()}
options={['all', 'matching feature only']}
/>
}
/>
</Box>
</Popup>
<Box width="600px" height="600px">
<Select
{...props}
value={value}
maxListHeight="initial"
options={customOptions}
onChange={handleChange}
customFilter={
<RadioGroup
ml="10px"
value={selected}
flexDirection="row"
onChange={handleChangeFilter}
onClick={(e) => e.stopPropagation()}
options={['all', 'matching feature only']}
/>
}
/>
</Box>
);
},
};
313 changes: 313 additions & 0 deletions src/components/single-select/index.tsx
Original file line number Diff line number Diff line change
@@ -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<PublicBaseSelectProps, 'menuPlacement'>;
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<SingleSelectOption>) => {
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 (
<FormControl
width={width}
display="flex"
alignItems="baseline"
justifyContent="start"
flexDirection={flexDirection}
isInvalid={isInvalid}
isDisabled={disabled}
{...props}
>
{['outside', 'side'].includes(labelPosition) && label && (
<FormLabel>
<Label
as="span"
text={label}
action={labelAction}
m={0}
sx={{
span: {
mb: 0,
mr: 0,
},
}}
/>
</FormLabel>
)}
<Component<SingleSelectOption>
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 && (
<FormErrorMessage m={1} fontSize="12px">
{errorMessage}
</FormErrorMessage>
)}
</FormControl>
);
};

//

const chakraStyles = ({
width,
maxListHeight,
variant,
}: Pick<Props, 'width' | 'maxListHeight' | 'variant'>) => ({
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 (
<chakraComponents.SingleValue {...props}>
<Flex
align="stretch"
w="max-content"
gap={1}
direction={
props.selectProps?.invertLabelPosition ? 'row-reverse' : 'row'
}
>
{props.selectProps?.labelPosition === 'inline' && (
<Text fontWeight="normal" color="gray" mr="0.5ch">
{props.selectProps?.label}
</Text>
)}
<Box>
{children} {/* This renders the options */}
</Box>
</Flex>
</chakraComponents.SingleValue>
);
};

const MenuList = ({ children, ...props }: any) => {
return (
<chakraComponents.MenuList {...props} background="red">
{children} {/* This renders the options */}
</chakraComponents.MenuList>
);
};

const Option = ({ children, ...props }: any) => {
return (
<chakraComponents.Option {...props} background="red">
<Flex w="full" gap={2}>
{children} {/* This renders the options */}
<Labeling gray>{props.data.additionalText}</Labeling>
<Box ml="auto">{props.data.additionalComponent}</Box>
</Flex>
</chakraComponents.Option>
);
};

const isNotEmptyAndNotUndefined = R.both(
R.complement(R.isNil),
R.complement(R.isEmpty),
);

const CreateLabel = (text: string) => (
<HStack align="baseline">
<Labeling fontSize="11px" gray>
add
</Labeling>
<Box>{text}</Box>
</HStack>
);
Loading

0 comments on commit f02b1d3

Please sign in to comment.