Skip to content

Commit

Permalink
Multiselect (#657)
Browse files Browse the repository at this point in the history
* wip: multiselect

* Feat: multiselect
  • Loading branch information
emielvanseveren authored Nov 12, 2023
1 parent 82a34b7 commit df2f1a4
Show file tree
Hide file tree
Showing 7 changed files with 185 additions and 24 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ export const Option: FC<OptionProps> = ({ children, index = 0, value, label }) =
<OptionContainer
ref={(node: any) => (listRef.current[index] = node)}
tabIndex={activeIndex === index ? 0 : 1}
isMultiSelect={false}
aria-selected={activeIndex === index}
{...getItemProps({
role: 'option',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const ControlledSelect: FC<ControlledSelectProps> & SubComponentTypes = (
disabled,
hint,
description,
multiSelect = false,
name,
control,
loading,
Expand Down Expand Up @@ -68,6 +69,10 @@ export const ControlledSelect: FC<ControlledSelectProps> & SubComponentTypes = (
hint={hint}
/>
)}

{/* Typescript cannot infer the correct types here*/}
{/*eslint-disable-next-line @typescript-eslint/ban-ts-comment */}
{/* @ts-ignore */}
<GenericSelect
name={name}
id={name}
Expand All @@ -78,7 +83,10 @@ export const ControlledSelect: FC<ControlledSelectProps> & SubComponentTypes = (
required={required}
size={componentSize}
enableFilter={enableFilter}
onChange={field.onChange}
multiSelect={multiSelect}
onChange={(e) => {
field.onChange(e);
}}
onBlur={handleOnBlur}
onFocus={handleOnFocus}
render={render}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,26 +3,59 @@ import { styled } from '../../../../styled';
import { SelectContext } from './context';
import { AiOutlineCheck as CheckIcon } from 'react-icons/ai';
import { OptionContainer } from '../style';
import { GenericCheckBox } from '../../CheckBox';

const StyledCheckIcon = styled(CheckIcon)`
margin-left: ${({ theme }) => theme.spacing[1]};
`;

export interface OptionProps extends PropsWithChildren {
value: string;
// Properties set by the Select component
index?: number;
onChange?: (value: string) => unknown;
onChange?: (value: string | string[]) => unknown;
}

// check if the index is already selected, if so remove it, otherwise add it.
function toggleSelectedIndex(selectedIndices: number[], index: number) {
if (selectedIndices.includes(index)) {
return selectedIndices.filter((i) => i !== index);
} else {
return [...selectedIndices, index];
}
}

// get the values from the selected indices
function getselectedValues(selectedIndices: number[], options: string[]): string[] {
return selectedIndices.map((i) => options[i]);
}

export const Option: FC<OptionProps> = ({ children, index = 0, value, onChange }) => {
const { selectedIndex, setSelectedIndex, listRef, setOpen, activeIndex, setActiveIndex, getItemProps, dataRef } =
useContext(SelectContext);
const {
selectedIndex,
setSelectedIndex,
listRef,
setOpen,
activeIndex,
setActiveIndex,
getItemProps,
dataRef,
multiSelect,
values,
name,
} = useContext(SelectContext);

function handleSelect() {
setSelectedIndex(index);
if (onChange) onChange(value);

setOpen(false);
if (multiSelect) {
// Since state updates are async, we cannot use the selectedIndex state in the onChange callback
const updatedIndices = toggleSelectedIndex(selectedIndex as number[], index);
setSelectedIndex(updatedIndices);
if (onChange) onChange(getselectedValues(updatedIndices, values));
} else {
setSelectedIndex(index);
if (onChange) onChange(value);
setOpen(false);
}
setActiveIndex(null);
}

Expand All @@ -48,6 +81,7 @@ export const Option: FC<OptionProps> = ({ children, index = 0, value, onChange }
role="option"
ref={(node: any) => (listRef.current[index] = node)}
tabIndex={activeIndex === index ? 0 : 1}
isMultiSelect={multiSelect}
isActive={activeIndex === index}
aria-selected={activeIndex === index}
data-selected={selectedIndex === index}
Expand All @@ -57,7 +91,21 @@ export const Option: FC<OptionProps> = ({ children, index = 0, value, onChange }
onKeyUp: handleKeyUp,
})}
>
<span>{children}</span> {selectedIndex === index && <StyledCheckIcon size={15} />}
{multiSelect && (
<GenericCheckBox
size="tiny"
id={`${name}-checkbox-${index}`}
hasDescription={false}
hasError={false}
onChange={() => {
/* bubbles up? */
}}
name={`${name}-checkbox-${index}`}
value={(selectedIndex as number[]).includes(index)}
/>
)}
<span style={{ marginLeft: multiSelect ? '10px' : 0 }}>{children}</span>{' '}
{!multiSelect && selectedIndex === index && <StyledCheckIcon size={15} />}
</OptionContainer>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,17 @@ import { createContext } from 'react';
import { ContextData } from '@floating-ui/react';

interface SelectContextValue {
selectedIndex: number;
setSelectedIndex: (index: number) => void;
selectedIndex: number | number[];
setSelectedIndex: (index: number | number[]) => void;
values: string[];
activeIndex: number | null;
setActiveIndex: (index: number | null) => void;
listRef: React.MutableRefObject<Array<HTMLLIElement | null>>;
setOpen: (open: boolean) => void;
getItemProps: (userProps?: React.HTMLProps<HTMLElement>) => any;
dataRef: ContextData;
multiSelect: boolean;
name: string;
}

export const SelectContext = createContext<SelectContextValue>({} as SelectContextValue);
Original file line number Diff line number Diff line change
Expand Up @@ -37,14 +37,25 @@ import { OptionGroup } from './OptionGroup';
import { SubComponentTypes } from '..';
import { setAriaDescribedBy } from '../../layout';

export interface SelectProps {
render: (selectedIndex: number) => React.ReactNode;
interface MultiSelectProps {
render: (selectIndex: number[]) => React.ReactNode;
multiSelect: true;
enableFilter?: boolean;
/// Rendering in portal will render the selectDropdown independent from its parent container.
/// this is useful when select is rendered in other floating elements with limited space.
inPortal?: boolean;
}

interface SingleSelectProps {
render: (selectIndex: number) => React.ReactNode;
multiSelect?: false;
enableFilter?: boolean;
/// Rendering in portal will render the selectDropdown independent from its parent container.
/// this is useful when select is rendered in other floating elements with limited space.
inPortal?: boolean;
}

export type SelectProps = MultiSelectProps | SingleSelectProps;
export type GenericSelectProps = PropsWithChildren<SelectProps & GenericInputProps<string, HTMLDivElement>>;

const defaultsApplier = defaultInputPropsFactory<GenericSelectProps>(defaultInputProps);
Expand All @@ -66,6 +77,7 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
name,
inPortal = true,
enableFilter = false,
multiSelect = false,
} = defaultsApplier(props);

const listItemsRef = useRef<Array<HTMLLIElement | null>>([]);
Expand All @@ -77,7 +89,11 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)

const [open, setOpen] = useState(false);
const [activeIndex, setActiveIndex] = useState<number | null>(null);
const [selectedIndex, setSelectedIndex] = useState(() => {
const [selectedIndex, setSelectedIndex] = useState<number | number[]>(() => {
if (multiSelect) {
return [];
}

const defaultIndex = Math.max(0, listContentRef.current.indexOf(value));
onChange(listContentRef.current[defaultIndex]);
return defaultIndex;
Expand Down Expand Up @@ -124,7 +140,7 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
useListNavigation(context, {
listRef: listItemsRef,
activeIndex,
selectedIndex,
selectedIndex: multiSelect ? null : (selectedIndex as number),
onNavigate: setActiveIndex,
}),
]);
Expand All @@ -139,12 +155,19 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
});
})?.flat() ?? []
);
}, []);
}, [children]);

/* This handles the case where the value is set by default by react-hook-form an initial value coming from the api.*/
useEffect(() => {
const index = values.indexOf(value);
if (index !== -1) {
setSelectedIndex(index + 1);
if (multiSelect && value && Array.isArray(value)) {
const values = value as unknown as string[];
const indices = values.map((v) => listContentRef.current.indexOf(v));
setSelectedIndex(indices);
} else {
const index = values.indexOf(value);
if (index !== -1) {
setSelectedIndex(index + 1);
}
}
}, [value]);

Expand Down Expand Up @@ -208,7 +231,10 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)

const renderSelect = () => {
return (
<FloatingFocusManager context={context} initialFocus={selectedIndex || filterInputRef}>
<FloatingFocusManager
context={context}
initialFocus={multiSelect ? (selectedIndex as number[])[0] : (selectedIndex as number) || filterInputRef}
>
<SelectContainer
ref={refs.setFloating}
style={{
Expand All @@ -232,6 +258,16 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
);
};

const renderContent = () => {
if (Array.isArray(selectedIndex)) {
// Typescript does not infer the correct type for render here
return render(selectedIndex as number[] & number);
} else {
// Typescript does not infer the correct type for render here
return render((selectedIndex - 1) as number[] & number);
}
};

return (
<SelectContext.Provider
value={{
Expand All @@ -243,6 +279,9 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
setOpen,
getItemProps,
dataRef: context.dataRef,
name,
multiSelect,
values,
}}
>
<SelectButton
Expand All @@ -257,7 +296,7 @@ export const GenericSelect: FC<GenericSelectProps> & SubComponentTypes = (props)
aria-describedby={setAriaDescribedBy(name, hasDescription)}
{...getReferenceProps()}
>
{render(selectedIndex - 1)}
{renderContent()}
{!readOnly && <StyledArrowIcon size={16} />}
</SelectButton>
{open &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,67 @@ export const OnSubmit: StoryFn<SelectProps> = (args) => {
);
};

export const MultiSelect: StoryFn<SelectProps> = (args) => {
type FormFields = { film: string[] };
const [result, setResult] = useState<string>('none');

const validationSchema = useMemo(
() =>
z.object({
film: z.string().array(),
}),
[]
);

const { control, handleSubmit } = useForm<FormFields>({
resolver: zodResolver(validationSchema),
});

const submit: SubmitHandler<FormFields> = ({ film }) => {
setResult(film.join(', '));
};

return (
<>
NOTE: You can ignore the width changing when opening the select. This is due to the select being rendered in a
storybook iframe which has incorrect gutter size.
<form onSubmit={handleSubmit(submit)}>
<Select
control={control}
name="film"
label={args.label}
multiSelect
description={args.description}
render={(selectedIndices) => (
<div>
{selectedIndices.length === 0
? 'Select...'
: selectedIndices.length <= 3
? selectedIndices.map((index) => films[index]?.name).join(', ')
: `${selectedIndices
.slice(0, 3)
.map((index) => films[index]?.name)
.join(', ')} and ${selectedIndices.length - 3} more`}
</div>
)}
>
<Select.OptionGroup label="films">
{films.map(({ name }) => (
<Select.Option key={name} value={name}>
<div>
<span>{name}</span>
</div>
</Select.Option>
))}
</Select.OptionGroup>
</Select>
<Button type="submit" text="Submit" />
</form>
<pre>result: {result}</pre>
</>
);
};

export const Filter: StoryFn<SelectProps & ExtraStoryProps> = (args) => {
const { control } = useForm();
const selectValue = useWatch({ control, name: 'film' });
Expand Down
7 changes: 4 additions & 3 deletions packages/lib-components/src/components/inputs/Select/style.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,15 @@ export const SelectContainer = styled.div`
z-index: ${({ theme }) => theme.zIndex.dropdown};
`;

export const OptionContainer = styled.div<{ isActive: boolean }>`
export const OptionContainer = styled.div<{ isActive: boolean; isMultiSelect: boolean }>`
padding: ${({ theme }) => `${theme.spacing['0_75']} ${theme.spacing['1']}`};
min-height: ${({ theme }) => theme.spacing[4]};
cursor: default;
border-radius: ${({ theme }) => theme.borderRadius.medium};
text-align: left;
display: flex;
align-items: center;
justify-content: space-between;
justify-content: ${({ isMultiSelect }) => (isMultiSelect ? 'flex-start' : 'space-between')};
transition: transform 0.15s ease-out;
outline: 0;
scroll-margin: ${({ theme }) => theme.spacing['0_75']};
Expand All @@ -89,7 +89,7 @@ export const OptionContainer = styled.div<{ isActive: boolean }>`
}
&:hover {
background-color: ${({ theme }) => theme.colors.primary};
background-color: ${({ theme }) => theme.colors.secondary};
span {
color: white;
}
Expand All @@ -101,6 +101,7 @@ export const OptionContainer = styled.div<{ isActive: boolean }>`
gap: ${({ theme }) => theme.spacing[1]};
span {
cursor: pointer;
color: ${({ theme, isActive: isSelected }) => (isSelected ? theme.colors.white : theme.colors.text)};
}
}
Expand Down

0 comments on commit df2f1a4

Please sign in to comment.