Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,6 @@ export const MultiSelectDropdown: Story = {
value: 'prod',
},
],
onBlur: () => console.log('blur'),
onChange: (args) => console.log('change', args),
},
render: (args) => (
<div className="max-w-[300px] flex flex-col gap-3">
Expand Down
16 changes: 15 additions & 1 deletion lib/components/MultiSelectDropdown/MultiSelectDropdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,17 @@ export const MultiSelectDropdown: FC<MultiSelectDropdownProps> = forwardRef<
MultiSelectDropdownProps
>(
(
{ options, multiselect, value, onChange, onBlur, name, ...delegated },
{
options,
multiselect,
value,
onChange,
onBlur,
name,
isLoading,
noOptionsText,
...delegated
},
ref,
) => (
<MultiSelectDropdownProvider
Expand All @@ -20,8 +30,12 @@ export const MultiSelectDropdown: FC<MultiSelectDropdownProps> = forwardRef<
onChange={onChange}
onBlur={onBlur}
name={name}
isLoading={isLoading}
noOptionsText={noOptionsText}
>
<Wrapper ref={ref} {...delegated} name={name} />
</MultiSelectDropdownProvider>
),
);

MultiSelectDropdown.displayName = 'MultiSelectDropdown';
11 changes: 9 additions & 2 deletions lib/components/MultiSelectDropdown/MultiSelectDropdown.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import { multiSelectDropdownVariants } from './MultiSelectDropdown.variants';
export type MultiSelectDropdownOption = {
id: string | number;
label: string;
tagLabel: string;
tagLabel?: string;
tagColor?: TagProps['color'];
value?: string;
};
Expand All @@ -17,6 +17,11 @@ type OnChangeFn = (params: {
target: { value: MultiSelectDropdownOption[]; name: string };
}) => void;

type OnBlurFn = (event: {
target: HTMLInputElement | null;
type?: string;
}) => void;

export interface MultiSelectDropdownProps
extends
VariantProps<typeof multiSelectDropdownVariants>,
Expand All @@ -33,5 +38,7 @@ export interface MultiSelectDropdownProps
multiselect?: boolean;
value?: MultiSelectDropdownOption[];
onChange?: OnChangeFn;
onBlur?: VoidFunction;
onBlur?: OnBlurFn;
isLoading?: boolean;
noOptionsText?: string;
}
23 changes: 15 additions & 8 deletions lib/components/MultiSelectDropdown/components/Item/Item.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,25 +8,32 @@ import { ItemProps } from './Item.types';
import { wrapperVariants } from './Item.variants';
import { Tag, Typography } from '@/components';

export const Item: FC<ItemProps> = ({ option, theme, isSelected }) => {
export const Item: FC<ItemProps> = ({
option,
theme,
isSelected,
className,
}) => {
const { onSelectOption } = useMultiSelectDropdown();

return (
<li
role="option"
data-theme={theme}
className={cn(wrapperVariants({ isSelected }))}
className={cn(wrapperVariants({ isSelected }), className)}
onClick={() => onSelectOption(option)}
>
<Typography variant="body2" className="text-slate-800">
{option.label}
</Typography>
<Tag
id={option.id}
label={option.tagLabel}
color={option.tagColor}
isSelected={isSelected}
/>
{option.tagLabel && (
<Tag
id={option.id}
label={option.tagLabel}
color={option.tagColor}
isSelected={isSelected}
/>
)}
</li>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,5 @@ export type ItemProps = {
option: MultiSelectDropdownOption;
theme?: Theme;
isSelected?: boolean;
className?: string;
};
28 changes: 19 additions & 9 deletions lib/components/MultiSelectDropdown/components/List/List.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { FC } from 'react';

import { cn } from '@/utils';
import { Typography } from '@/components';

import { Item } from '../Item/Item';
import { useMultiSelectDropdown } from '../../contexts';

import { ListProps } from './List.types';
import { wrapperVariants } from './List.variants';
import { Typography } from '@/components';

export const List: FC<ListProps> = ({ theme }) => {
const { options, selectedOptions } = useMultiSelectDropdown();
const { options, selectedOptions, isLoading, noOptionsText } =
useMultiSelectDropdown();

return (
<ul role="listbox" data-theme={theme} className={cn(wrapperVariants())}>
{options.length > 0 ? (
{isLoading ? (
<Item
key="loading"
option={{ id: 'loading', label: 'Loading...' }}
className="select-none pointer-events-none"
isSelected={false}
/>
) : options.length > 0 ? (
options.map((option) => (
<Item
key={option.id}
Expand All @@ -25,12 +33,14 @@ export const List: FC<ListProps> = ({ theme }) => {
/>
))
) : (
<Typography
variant="body2"
className="text-zinc-800 dark:text-slate-50 italic px-2 py-2"
>
No options
</Typography>
<li className="select-none">
<Typography
variant="body2"
className="text-zinc-800 dark:text-slate-50 italic px-2 py-2"
>
{noOptionsText ?? 'No options'}
</Typography>
</li>
)}
</ul>
);
Expand Down
33 changes: 23 additions & 10 deletions lib/components/MultiSelectDropdown/components/Wrapper/Wrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { FC, forwardRef, useId, useImperativeHandle } from 'react';
import { ChevronUp } from 'react-feather';

import { Tag } from '@/components/Tag/Tag';
import { Tag } from '@/components';
import Loader from '@/assets/icons/loader.svg';
import { cn } from '@/utils';

import { useMultiSelectDropdown as useMultiSelectDropdownContext } from '../../contexts';
Expand All @@ -25,8 +26,14 @@ export const Wrapper: FC<WrapperProps> = forwardRef<
ref,
) => {
const id = useId();
const { selectedOptions, isOpen, onOpen, onRemoveOption, inputRef } =
useMultiSelectDropdownContext();
const {
selectedOptions,
isOpen,
onOpen,
onRemoveOption,
inputRef,
isLoading,
} = useMultiSelectDropdownContext();
const { wrapperRef, handleOpen } = useMultiSelectDropdown();

useImperativeHandle(ref, () => inputRef!.current!, [inputRef]);
Expand Down Expand Up @@ -72,7 +79,7 @@ export const Wrapper: FC<WrapperProps> = forwardRef<
<Tag
key={option.id}
id={option.id}
label={option.tagLabel}
label={option.tagLabel || option.label || ''}
color={option.tagColor || 'gray-800'}
className="select-none gap-2"
rightIcon={
Expand All @@ -87,12 +94,16 @@ export const Wrapper: FC<WrapperProps> = forwardRef<
</div>
)}

<ChevronUp
className={cn(
'w-4 h-4 text-inherit transition-all duration-50 shrink-0',
isOpen ? 'rotate-0' : 'rotate-180',
)}
/>
{isLoading ? (
<Loader className="w-4 h-4 text-slate-400 animate-spin shrink-0" />
) : (
<ChevronUp
className={cn(
'w-4 h-4 text-inherit transition-all duration-50 shrink-0',
isOpen ? 'rotate-0' : 'rotate-180',
)}
/>
)}
</div>

<input
Expand All @@ -108,3 +119,5 @@ export const Wrapper: FC<WrapperProps> = forwardRef<
);
},
);

Wrapper.displayName = 'MultiSelectDropdownWrapper';
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ const initialState: State = {
onOpen() {
throw new Error('Function not implemented.');
},
isLoading: false,
noOptionsText: undefined,
};

export const MultiSelectDropdownContext = createContext<State>(initialState);
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const MultiSelectDropdownProvider: FC<
onChange,
onBlur,
name,
isLoading,
noOptionsText,
}) => {
const inputRef = useRef<ComponentRef<'input'>>(null);
const [isOpen, setIsOpen] = useToggle(false);
Expand All @@ -33,7 +35,24 @@ export const MultiSelectDropdownProvider: FC<
>([]);
const isControlled = value !== undefined;

// Sync value prop to selected options
// Sync defaultOptions to options state (for uncontrolled mode)
useEffect(() => {
if (!isControlled) {
const selectedIdsSet = new Set(
selectedOptions.map((option) => option.id),
);
setOptions(
multiselect
? defaultOptions.filter((option) => !selectedIdsSet.has(option.id))
: defaultOptions.map((option) => ({
...option,
isSelected: selectedIdsSet.has(option.id),
})),
);
}
}, [defaultOptions, multiselect, isControlled, selectedOptions]);

// Sync value prop to selected options (for controlled mode)
useEffect(() => {
if (isControlled) {
const selected = value || [];
Expand Down Expand Up @@ -83,11 +102,14 @@ export const MultiSelectDropdownProvider: FC<
setIsOpen(value);

// Call onBlur when closing the dropdown
if (wasOpen && value === false && onBlur) {
onBlur();
if (wasOpen && value === false && onBlur && inputRef.current) {
onBlur({
target: inputRef.current,
type: 'blur',
});
}
},
[isOpen, setIsOpen, onBlur],
[isOpen, setIsOpen, onBlur, inputRef],
);

const handleSelectOption = useCallback(
Expand Down Expand Up @@ -179,6 +201,8 @@ export const MultiSelectDropdownProvider: FC<
onSelectOption: handleSelectOption,
onRemoveOption: handleRemoveOption,
onOpen: handleOpen,
isLoading,
noOptionsText,
}}
>
{children}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ export type State = {
onSelectOption: (option: MultiSelectDropdownOption) => void;
onRemoveOption: (option: MultiSelectDropdownOption) => void;
onOpen: (value?: boolean) => void;
isLoading?: boolean;
noOptionsText?: string;
};

export type MultiSelectDropdownProviderProps = PropsWithChildren & {
Expand All @@ -19,6 +21,8 @@ export type MultiSelectDropdownProviderProps = PropsWithChildren & {
onChange?: (params: {
target: { value: MultiSelectDropdownOption[]; name: string };
}) => void;
onBlur?: VoidFunction;
onBlur?: (event: { target: HTMLInputElement | null; type?: string }) => void;
name?: string;
isLoading?: boolean;
noOptionsText?: string;
};