Skip to content

Commit ccf8ed3

Browse files
feat: add multiselect and dropdown
1 parent 9b5e160 commit ccf8ed3

17 files changed

+877
-2
lines changed

eslint.config.js

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,6 @@ module.exports = [
1919
...typescript.configs['recommended'].rules,
2020
...react.configs['recommended'].rules,
2121
'no-console': ['warn', { allow: ['warn', 'error'] }],
22-
'@typescript-eslint/no-explicit-any': 'off',
2322
'@typescript-eslint/no-unused-vars': 'warn'
2423
},
2524
settings: {
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import { LightningIconSolid } from '../../icons';
2+
import { numberFormat } from '../../utils';
3+
import { cn } from '../../utils/cn';
4+
import React from 'react'
5+
6+
export type BaseSelectContextTypeForList = {
7+
isListOpen: boolean;
8+
currentNavigateCheckbox: string;
9+
containerRef: React.MutableRefObject<HTMLDivElement> | null;
10+
}
11+
12+
export type SelectOption = {
13+
label: string;
14+
count?: number;
15+
value: string;
16+
selected: boolean;
17+
};
18+
19+
type StyleConfig = {
20+
container?: string;
21+
optionWrapper?: string;
22+
selectedOption?: string;
23+
optionInner?: string;
24+
icon?: string;
25+
label?: string;
26+
count?: string;
27+
noResults?: string;
28+
};
29+
30+
export type OnOptionSelect = ({action, value, event}: {action: "select" | "deselect", value: string, event: React.MouseEvent}) => void;
31+
32+
export type SelectListProps = {
33+
options: SelectOption[];
34+
label: string;
35+
onOptionSelect: OnOptionSelect;
36+
className?: string;
37+
styles?: StyleConfig;
38+
noResultsMessage?: string; // New: Customizable empty state
39+
selectContextData: BaseSelectContextTypeForList;
40+
};
41+
42+
const defaultStyles = {
43+
container: "scroller font-medium mt-2 max-h-[300px] py-[6px] overflow-auto border border-bdp-stroke rounded-xl data-[is-open='false']:hidden",
44+
optionWrapper: `flex gap-1 py-1 2xl:py-2 px-[14px] group/checkOption hover:bg-bdp-hover-state data-[current-navigated=true]:bg-bdp-hover-state
45+
group-hover/container:data-[current-navigated=true]:bg-transparent
46+
group-hover/container:data-[current-navigated=true]:hover:bg-bdp-hover-state
47+
data-[selected=true]:text-bdp-accent text-bdp-primary-text`,
48+
optionInner: "selectable-option flex grow items-center gap-3",
49+
icon: "shrink-0 group-data-[selected=false]/checkOption:invisible w-[12px] 2xl:w-[16px] h-auto",
50+
label: "grow capitalize text-sm 2xl:text-base group-data-[selected=true]/checkOption:font-bold",
51+
count: "shrink-0 group-data-[selected=true]/checkOption:font-medium",
52+
noResults: "w-full text-sm 2xl:text-base text-center px-2"
53+
} as const;
54+
55+
const BaseSelectList = ({options,
56+
label,
57+
onOptionSelect,
58+
className,
59+
styles = {},
60+
noResultsMessage = "No matching options",
61+
selectContextData
62+
}: SelectListProps) => {
63+
const {isListOpen, currentNavigateCheckbox, containerRef} = selectContextData;
64+
return (
65+
<div
66+
data-is-open={isListOpen}
67+
ref={containerRef}
68+
className={cn(
69+
defaultStyles.container,
70+
// "data-[is-open='false']:hidden",
71+
styles.container,
72+
className
73+
)}
74+
>
75+
{options.length < 1 && (
76+
<p className={cn(defaultStyles.noResults, styles.noResults)}>
77+
{noResultsMessage}
78+
</p>
79+
)}
80+
{options?.map((option) => {
81+
const checked = option.selected;
82+
const value = option.value;
83+
return (
84+
<label
85+
key={option.label}
86+
htmlFor={`checkbox-${label}-${option.label}`}
87+
data-checkbox={option.label}
88+
>
89+
<div
90+
data-selected={checked}
91+
data-current-navigated={option.label === currentNavigateCheckbox}
92+
className={cn(
93+
defaultStyles.optionWrapper,
94+
styles.optionWrapper
95+
)}
96+
onClick={(event) =>
97+
onOptionSelect({ action: "select", value, event })
98+
}
99+
role="button"
100+
aria-label={`${
101+
checked ? "uncheck" : "check"
102+
} filter ${label}:${option.label}`}
103+
>
104+
<div
105+
className={cn(defaultStyles.optionInner, styles.optionInner)}
106+
id={`example_facet_${label}${option.label}`}
107+
>
108+
<LightningIconSolid
109+
className={cn(defaultStyles.icon, styles.icon)}
110+
/>
111+
<span className={cn(defaultStyles.label, styles.label)}>
112+
{option.label}
113+
</span>
114+
</div>
115+
{option.count ? (
116+
<span className={cn(defaultStyles.count, styles.count)}>
117+
{numberFormat.format(option.count)}
118+
</span>
119+
) : null}
120+
</div>
121+
</label>
122+
);
123+
})}
124+
</div>
125+
);
126+
}
127+
128+
export default BaseSelectList;

src/components/select/Dropdown.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
"use client";
2+
3+
import React, {
4+
createContext,
5+
useCallback,
6+
useState,
7+
} from 'react';
8+
import SingleSelectList, { SingleSelectListProps, SingleSelectOption } from './SingleSelectList';
9+
import SingleSelectTrigger, { SingleSelectTriggerProps } from './SingleSelectInput';
10+
11+
12+
type StyleConfig = {
13+
container?: string;
14+
input?: string;
15+
list?: string;
16+
option?: string;
17+
};
18+
19+
type SelectContextType = {
20+
isListOpen: boolean;
21+
toggleListOpen: () => void;
22+
selectedOption: SingleSelectOption | null;
23+
setSelectedOption: (option: SingleSelectOption | null) => void;
24+
containerRef: React.MutableRefObject<HTMLDivElement> | null;
25+
setContainerRef: React.Dispatch<
26+
React.SetStateAction<React.MutableRefObject<HTMLDivElement> | null>
27+
>;
28+
handleSelectOption: (option: SingleSelectOption) => void;
29+
triggerRef: React.RefObject<HTMLDivElement>;
30+
};
31+
32+
const SingleSelectContext = createContext<SelectContextType | null>(null);
33+
export const useSingleSelect = () => {
34+
const context = React.useContext(SingleSelectContext);
35+
if (!context) {
36+
throw new Error("useSingleSelect must be used within a SingleSelectProvider");
37+
}
38+
return context;
39+
};
40+
41+
type SingleSelectProviderProps = {
42+
children: React.ReactNode;
43+
triggerRef: React.RefObject<HTMLDivElement>;
44+
className?: string;
45+
styles?: StyleConfig;
46+
disabled?: boolean;
47+
};
48+
49+
const SingleSelectProvider = ({
50+
children,
51+
triggerRef,
52+
disabled = false
53+
}: SingleSelectProviderProps) => {
54+
const [isListOpen, setIsListOpen] = useState(false);
55+
const [containerRef, setContainerRef] =
56+
useState<React.MutableRefObject<HTMLDivElement> | null>(null);
57+
const [selectedOption, setSelectedOption] = useState<SingleSelectOption | null>(null);
58+
59+
const toggleListOpen = () => {
60+
if (!disabled) {
61+
setIsListOpen(prev => !prev);
62+
}
63+
};
64+
65+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
66+
const handleSelectOption = (_option: SingleSelectOption) => {
67+
setIsListOpen(false);
68+
};
69+
70+
const handleClickOutside = useCallback((event: MouseEvent) => {
71+
if (
72+
containerRef?.current && triggerRef?.current &&
73+
!containerRef.current.contains(event.target as Node) &&
74+
!triggerRef.current.contains(event.target as Node)
75+
) {
76+
setIsListOpen(false);
77+
}
78+
}, [containerRef, isListOpen]);
79+
80+
React.useEffect(() => {
81+
document.addEventListener('mousedown', handleClickOutside);
82+
return () => {
83+
document.removeEventListener('mousedown', handleClickOutside);
84+
};
85+
}, [containerRef]);
86+
87+
const contextValue = {
88+
isListOpen,
89+
toggleListOpen,
90+
selectedOption,
91+
setSelectedOption,
92+
handleSelectOption,
93+
containerRef,
94+
setContainerRef,
95+
triggerRef,
96+
};
97+
98+
return (
99+
<SingleSelectContext.Provider value={contextValue}>
100+
<div className='relative'>
101+
{children}
102+
</div>
103+
</SingleSelectContext.Provider>
104+
);
105+
};
106+
107+
export const SingleSelect: React.FC<Omit<SingleSelectProviderProps, "triggerRef">> & {
108+
List: React.FC<SingleSelectListProps>
109+
Trigger: React.FC<SingleSelectTriggerProps>
110+
} = ({ children, disabled = false }: Omit<SingleSelectProviderProps, "triggerRef">) => {
111+
const triggerRef = React.useRef<HTMLDivElement>(null);
112+
return (
113+
<SingleSelectProvider disabled={disabled} triggerRef={triggerRef}>
114+
{children}
115+
</SingleSelectProvider>
116+
)
117+
}
118+
119+
SingleSelect.List = SingleSelectList;
120+
SingleSelect.Trigger = SingleSelectTrigger;
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import React, { useState } from "react";
2+
import useCheckboxNavigate from "./useSelectNavigate";
3+
import SelectInput, { SelectInputProps } from "./SelectInput";
4+
import SelectList, { MultiSelectListProps } from "./SelectList";
5+
6+
export type SelectContextType = {
7+
containerRef: React.MutableRefObject<HTMLDivElement> | null;
8+
setContainerRef: React.Dispatch<
9+
React.SetStateAction<React.MutableRefObject<HTMLDivElement> | null>
10+
>;
11+
searchInputRef: React.MutableRefObject<HTMLInputElement> | null;
12+
setSearchInputRef: React.Dispatch<
13+
React.SetStateAction<React.MutableRefObject<HTMLInputElement> | null>
14+
>;
15+
isListOpen: boolean;
16+
toggleListOpen: () => void;
17+
currentNavigateCheckbox: string;
18+
toggleRefocus: () => void;
19+
onSearch: (value: string) => void;
20+
inputValue: string;
21+
};
22+
23+
type SelectProviderProps = {
24+
children: React.ReactNode;
25+
isCollapsible?: boolean;
26+
};
27+
28+
const SelectContext = React.createContext<SelectContextType | null>(null);
29+
export const useMultiSelect = () => {
30+
const context = React.useContext(SelectContext);
31+
if (!context) {
32+
throw new Error("useMultiSelect must be used within a MultiSelectProvider");
33+
}
34+
return context;
35+
}
36+
37+
export const MultiSelectProvider = ({ children, isCollapsible = true }: SelectProviderProps) => {
38+
const [containerRef, setContainerRef] =
39+
useState<React.MutableRefObject<HTMLDivElement> | null>(null);
40+
const [searchInputRef, setSearchInputRef] =
41+
useState<React.MutableRefObject<HTMLInputElement> | null>(null);
42+
43+
const [isListOpen, setIsListOpen] = useState(true);
44+
45+
const toggleListOpen = () => {
46+
if (!isCollapsible) return;
47+
setIsListOpen(prev => !prev)
48+
}
49+
50+
const [inputValue, setInputValue] = useState("");
51+
52+
const {currentNavigateCheckbox, toggleRefocus} = useCheckboxNavigate({checkboxContainer: containerRef, searchEl: searchInputRef, options: []})
53+
54+
// const [currentNavigateCheckbox, setcurrentNavigateCheckbox] = useState("")
55+
const onSearch = (value: string) => {
56+
const newValue = value.trim();
57+
setInputValue(newValue);
58+
}
59+
60+
return (
61+
<SelectContext.Provider
62+
value={{
63+
containerRef,
64+
setContainerRef,
65+
searchInputRef,
66+
setSearchInputRef,
67+
isListOpen,
68+
toggleListOpen,
69+
currentNavigateCheckbox,
70+
toggleRefocus,
71+
onSearch,
72+
inputValue,
73+
}}
74+
>
75+
{children}
76+
</SelectContext.Provider>
77+
);
78+
};
79+
80+
export const MultiSelect: React.FC<SelectProviderProps> & {
81+
Input: React.FC<SelectInputProps>;
82+
List: React.FC<MultiSelectListProps>;
83+
} = ({ children, isCollapsible = true }: SelectProviderProps) => {
84+
return (
85+
<MultiSelectProvider isCollapsible={isCollapsible}>
86+
{children}
87+
</MultiSelectProvider>
88+
);
89+
}
90+
91+
MultiSelect.Input = SelectInput;
92+
MultiSelect.List = SelectList;

0 commit comments

Comments
 (0)