Skip to content

Commit 6a2147b

Browse files
feat: add multiselect and dropdown
1 parent 1f58a4c commit 6a2147b

17 files changed

+918
-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: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
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 = ({
31+
action,
32+
value,
33+
event,
34+
}: {
35+
action: "select" | "deselect";
36+
value: string;
37+
event: React.MouseEvent;
38+
}) => void;
39+
40+
export type SelectListProps = {
41+
options: SelectOption[];
42+
label: string;
43+
onOptionSelect: OnOptionSelect;
44+
className?: string;
45+
styles?: StyleConfig;
46+
noResultsMessage?: string; // New: Customizable empty state
47+
selectContextData: BaseSelectContextTypeForList;
48+
};
49+
50+
const defaultStyles = {
51+
container:
52+
"scroller font-medium mt-2 max-h-[300px] py-[6px] overflow-auto border border-bdp-stroke rounded-xl data-[is-open='false']:hidden",
53+
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
54+
group-hover/container:data-[current-navigated=true]:bg-transparent
55+
group-hover/container:data-[current-navigated=true]:hover:bg-bdp-hover-state
56+
data-[selected=true]:text-bdp-accent text-bdp-primary-text`,
57+
optionInner: "selectable-option flex grow items-center gap-3",
58+
icon: "shrink-0 group-data-[selected=false]/checkOption:invisible w-[12px] 2xl:w-[16px] h-auto",
59+
label:
60+
"grow capitalize text-sm 2xl:text-base group-data-[selected=true]/checkOption:font-bold",
61+
count: "shrink-0 group-data-[selected=true]/checkOption:font-medium",
62+
noResults: "w-full text-sm 2xl:text-base text-center px-2",
63+
} as const;
64+
65+
const BaseSelectList = ({
66+
options,
67+
label,
68+
onOptionSelect,
69+
className,
70+
styles = {},
71+
noResultsMessage = "No matching options",
72+
selectContextData,
73+
}: SelectListProps) => {
74+
const { isListOpen, currentNavigateCheckbox, containerRef } =
75+
selectContextData;
76+
return (
77+
<div
78+
data-is-open={isListOpen}
79+
ref={containerRef}
80+
className={cn(
81+
defaultStyles.container,
82+
// "data-[is-open='false']:hidden",
83+
styles.container,
84+
className,
85+
)}
86+
>
87+
{options.length < 1 && (
88+
<p className={cn(defaultStyles.noResults, styles.noResults)}>
89+
{noResultsMessage}
90+
</p>
91+
)}
92+
{options?.map((option) => {
93+
const checked = option.selected;
94+
const value = option.value;
95+
return (
96+
<label
97+
key={option.label}
98+
htmlFor={`checkbox-${label}-${option.label}`}
99+
data-checkbox={option.label}
100+
>
101+
<div
102+
data-selected={checked}
103+
data-current-navigated={option.label === currentNavigateCheckbox}
104+
className={cn(defaultStyles.optionWrapper, styles.optionWrapper)}
105+
onClick={(event) =>
106+
onOptionSelect({ action: "select", value, event })
107+
}
108+
role="button"
109+
aria-label={`${
110+
checked ? "uncheck" : "check"
111+
} filter ${label}:${option.label}`}
112+
>
113+
<div
114+
className={cn(defaultStyles.optionInner, styles.optionInner)}
115+
id={`example_facet_${label}${option.label}`}
116+
>
117+
<LightningIconSolid
118+
className={cn(defaultStyles.icon, styles.icon)}
119+
/>
120+
<span className={cn(defaultStyles.label, styles.label)}>
121+
{option.label}
122+
</span>
123+
</div>
124+
{option.count ? (
125+
<span className={cn(defaultStyles.count, styles.count)}>
126+
{numberFormat.format(option.count)}
127+
</span>
128+
) : null}
129+
</div>
130+
</label>
131+
);
132+
})}
133+
</div>
134+
);
135+
};
136+
137+
export default BaseSelectList;

src/components/select/Dropdown.tsx

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
"use client";
2+
3+
import React, { createContext, useCallback, useState } from "react";
4+
import SingleSelectList, {
5+
SingleSelectListProps,
6+
SingleSelectOption,
7+
} from "./SingleSelectList";
8+
import SingleSelectTrigger, {
9+
SingleSelectTriggerProps,
10+
} from "./SingleSelectInput";
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(
37+
"useSingleSelect must be used within a SingleSelectProvider",
38+
);
39+
}
40+
return context;
41+
};
42+
43+
type SingleSelectProviderProps = {
44+
children: React.ReactNode;
45+
triggerRef: React.RefObject<HTMLDivElement>;
46+
className?: string;
47+
styles?: StyleConfig;
48+
disabled?: boolean;
49+
};
50+
51+
const SingleSelectProvider = ({
52+
children,
53+
triggerRef,
54+
disabled = false,
55+
}: SingleSelectProviderProps) => {
56+
const [isListOpen, setIsListOpen] = useState(false);
57+
const [containerRef, setContainerRef] =
58+
useState<React.MutableRefObject<HTMLDivElement> | null>(null);
59+
const [selectedOption, setSelectedOption] =
60+
useState<SingleSelectOption | null>(null);
61+
62+
const toggleListOpen = () => {
63+
if (!disabled) {
64+
setIsListOpen((prev) => !prev);
65+
}
66+
};
67+
68+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
69+
const handleSelectOption = (_option: SingleSelectOption) => {
70+
setIsListOpen(false);
71+
};
72+
73+
const handleClickOutside = useCallback(
74+
(event: MouseEvent) => {
75+
if (
76+
containerRef?.current &&
77+
triggerRef?.current &&
78+
!containerRef.current.contains(event.target as Node) &&
79+
!triggerRef.current.contains(event.target as Node)
80+
) {
81+
setIsListOpen(false);
82+
}
83+
},
84+
[containerRef, isListOpen],
85+
);
86+
87+
React.useEffect(() => {
88+
document.addEventListener("mousedown", handleClickOutside);
89+
return () => {
90+
document.removeEventListener("mousedown", handleClickOutside);
91+
};
92+
}, [containerRef]);
93+
94+
const contextValue = {
95+
isListOpen,
96+
toggleListOpen,
97+
selectedOption,
98+
setSelectedOption,
99+
handleSelectOption,
100+
containerRef,
101+
setContainerRef,
102+
triggerRef,
103+
};
104+
105+
return (
106+
<SingleSelectContext.Provider value={contextValue}>
107+
<div className="relative">{children}</div>
108+
</SingleSelectContext.Provider>
109+
);
110+
};
111+
112+
export const SingleSelect: React.FC<
113+
Omit<SingleSelectProviderProps, "triggerRef">
114+
> & {
115+
List: React.FC<SingleSelectListProps>;
116+
Trigger: React.FC<SingleSelectTriggerProps>;
117+
} = ({
118+
children,
119+
disabled = false,
120+
}: Omit<SingleSelectProviderProps, "triggerRef">) => {
121+
const triggerRef = React.useRef<HTMLDivElement>(null);
122+
return (
123+
<SingleSelectProvider disabled={disabled} triggerRef={triggerRef}>
124+
{children}
125+
</SingleSelectProvider>
126+
);
127+
};
128+
129+
SingleSelect.List = SingleSelectList;
130+
SingleSelect.Trigger = SingleSelectTrigger;

src/components/select/MultiSelect.tsx

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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 = ({
38+
children,
39+
isCollapsible = true,
40+
}: SelectProviderProps) => {
41+
const [containerRef, setContainerRef] =
42+
useState<React.MutableRefObject<HTMLDivElement> | null>(null);
43+
const [searchInputRef, setSearchInputRef] =
44+
useState<React.MutableRefObject<HTMLInputElement> | null>(null);
45+
46+
const [isListOpen, setIsListOpen] = useState(true);
47+
48+
const toggleListOpen = () => {
49+
if (!isCollapsible) return;
50+
setIsListOpen((prev) => !prev);
51+
};
52+
53+
const [inputValue, setInputValue] = useState("");
54+
55+
const { currentNavigateCheckbox, toggleRefocus } = useCheckboxNavigate({
56+
checkboxContainer: containerRef,
57+
searchEl: searchInputRef,
58+
options: [],
59+
});
60+
61+
// const [currentNavigateCheckbox, setcurrentNavigateCheckbox] = useState("")
62+
const onSearch = (value: string) => {
63+
const newValue = value.trim();
64+
setInputValue(newValue);
65+
};
66+
67+
return (
68+
<SelectContext.Provider
69+
value={{
70+
containerRef,
71+
setContainerRef,
72+
searchInputRef,
73+
setSearchInputRef,
74+
isListOpen,
75+
toggleListOpen,
76+
currentNavigateCheckbox,
77+
toggleRefocus,
78+
onSearch,
79+
inputValue,
80+
}}
81+
>
82+
{children}
83+
</SelectContext.Provider>
84+
);
85+
};
86+
87+
export const MultiSelect: React.FC<SelectProviderProps> & {
88+
Input: React.FC<SelectInputProps>;
89+
List: React.FC<MultiSelectListProps>;
90+
} = ({ children, isCollapsible = true }: SelectProviderProps) => {
91+
return (
92+
<MultiSelectProvider isCollapsible={isCollapsible}>
93+
{children}
94+
</MultiSelectProvider>
95+
);
96+
};
97+
98+
MultiSelect.Input = SelectInput;
99+
MultiSelect.List = SelectList;

0 commit comments

Comments
 (0)