Skip to content

Commit d6b1cab

Browse files
committed
chore: change table ui to shadcn datatable and pre-built multi-select components
1 parent 29e8d1b commit d6b1cab

File tree

8 files changed

+1153
-407
lines changed

8 files changed

+1153
-407
lines changed

project/apps/web/app/questions/page.tsx

Lines changed: 222 additions & 192 deletions
Large diffs are not rendered by default.
Lines changed: 381 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,381 @@
1+
// src/components/multi-select.tsx
2+
3+
import * as React from "react";
4+
import { cva, type VariantProps } from "class-variance-authority";
5+
import {
6+
CheckIcon,
7+
XCircle,
8+
ChevronDown,
9+
XIcon,
10+
WandSparkles,
11+
} from "lucide-react";
12+
13+
import { cn } from "@/lib/utils";
14+
import { Separator } from "@/components/ui/separator";
15+
import { Button } from "@/components/ui/button";
16+
import { Badge } from "@/components/ui/badge";
17+
import {
18+
Popover,
19+
PopoverContent,
20+
PopoverTrigger,
21+
} from "@/components/ui/popover";
22+
import {
23+
Command,
24+
CommandEmpty,
25+
CommandGroup,
26+
CommandInput,
27+
CommandItem,
28+
CommandList,
29+
CommandSeparator,
30+
} from "@/components/ui/command";
31+
32+
/**
33+
* Variants for the multi-select component to handle different styles.
34+
* Uses class-variance-authority (cva) to define different styles based on "variant" prop.
35+
*/
36+
const multiSelectVariants = cva(
37+
"m-1 transition ease-in-out delay-150 hover:-translate-y-1 hover:scale-110 duration-300",
38+
{
39+
variants: {
40+
variant: {
41+
default:
42+
"border-foreground/10 text-foreground bg-card hover:bg-card/80",
43+
secondary:
44+
"border-foreground/10 bg-secondary text-secondary-foreground hover:bg-secondary/80",
45+
destructive:
46+
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
47+
inverted: "inverted",
48+
},
49+
},
50+
defaultVariants: {
51+
variant: "default",
52+
},
53+
}
54+
);
55+
56+
/**
57+
* Props for MultiSelect component
58+
*/
59+
interface MultiSelectProps
60+
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
61+
VariantProps<typeof multiSelectVariants> {
62+
/**
63+
* An array of option objects to be displayed in the multi-select component.
64+
* Each option object has a label, value, and an optional icon.
65+
*/
66+
options: {
67+
/** The text to display for the option. */
68+
label: string;
69+
/** The unique value associated with the option. */
70+
value: string;
71+
/** Optional icon component to display alongside the option. */
72+
icon?: React.ComponentType<{ className?: string }>;
73+
}[];
74+
75+
/**
76+
* Callback function triggered when the selected values change.
77+
* Receives an array of the new selected values.
78+
*/
79+
onValueChange: (value: string[]) => void;
80+
81+
/** The default selected values when the component mounts. */
82+
defaultValue?: string[];
83+
84+
/**
85+
* Placeholder text to be displayed when no values are selected.
86+
* Optional, defaults to "Select options".
87+
*/
88+
placeholder?: string;
89+
90+
/**
91+
* Animation duration in seconds for the visual effects (e.g., bouncing badges).
92+
* Optional, defaults to 0 (no animation).
93+
*/
94+
animation?: number;
95+
96+
/**
97+
* Maximum number of items to display. Extra selected items will be summarized.
98+
* Optional, defaults to 3.
99+
*/
100+
maxCount?: number;
101+
102+
/**
103+
* The modality of the popover. When set to true, interaction with outside elements
104+
* will be disabled and only popover content will be visible to screen readers.
105+
* Optional, defaults to false.
106+
*/
107+
modalPopover?: boolean;
108+
109+
/**
110+
* If true, renders the multi-select component as a child of another component.
111+
* Optional, defaults to false.
112+
*/
113+
asChild?: boolean;
114+
115+
/**
116+
* Additional class names to apply custom styles to the multi-select component.
117+
* Optional, can be used to add custom styles.
118+
*/
119+
className?: string;
120+
}
121+
122+
export const MultiSelect = React.forwardRef<
123+
HTMLButtonElement,
124+
MultiSelectProps
125+
>(
126+
(
127+
{
128+
options,
129+
onValueChange,
130+
variant,
131+
defaultValue = [],
132+
placeholder = "Select options",
133+
animation = 0,
134+
maxCount = 3,
135+
modalPopover = false,
136+
asChild = false,
137+
className,
138+
...props
139+
},
140+
ref
141+
) => {
142+
const [selectedValues, setSelectedValues] =
143+
React.useState<string[]>(defaultValue);
144+
const [isPopoverOpen, setIsPopoverOpen] = React.useState(false);
145+
const [isAnimating, setIsAnimating] = React.useState(false);
146+
147+
const handleInputKeyDown = (
148+
event: React.KeyboardEvent<HTMLInputElement>
149+
) => {
150+
if (event.key === "Enter") {
151+
setIsPopoverOpen(true);
152+
} else if (event.key === "Backspace" && !event.currentTarget.value) {
153+
const newSelectedValues = [...selectedValues];
154+
newSelectedValues.pop();
155+
setSelectedValues(newSelectedValues);
156+
onValueChange(newSelectedValues);
157+
}
158+
};
159+
160+
const toggleOption = (option: string) => {
161+
const newSelectedValues = selectedValues.includes(option)
162+
? selectedValues.filter((value) => value !== option)
163+
: [...selectedValues, option];
164+
setSelectedValues(newSelectedValues);
165+
onValueChange(newSelectedValues);
166+
};
167+
168+
const handleClear = () => {
169+
setSelectedValues([]);
170+
onValueChange([]);
171+
};
172+
173+
const handleTogglePopover = () => {
174+
setIsPopoverOpen((prev) => !prev);
175+
};
176+
177+
const clearExtraOptions = () => {
178+
const newSelectedValues = selectedValues.slice(0, maxCount);
179+
setSelectedValues(newSelectedValues);
180+
onValueChange(newSelectedValues);
181+
};
182+
183+
const toggleAll = () => {
184+
if (selectedValues.length === options.length) {
185+
handleClear();
186+
} else {
187+
const allValues = options.map((option) => option.value);
188+
setSelectedValues(allValues);
189+
onValueChange(allValues);
190+
}
191+
};
192+
193+
return (
194+
<Popover
195+
open={isPopoverOpen}
196+
onOpenChange={setIsPopoverOpen}
197+
modal={modalPopover}
198+
>
199+
<PopoverTrigger asChild>
200+
<Button
201+
ref={ref}
202+
{...props}
203+
onClick={handleTogglePopover}
204+
className={cn(
205+
"flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-inherit",
206+
className
207+
)}
208+
>
209+
{selectedValues.length > 0 ? (
210+
<div className="flex justify-between items-center w-full">
211+
<div className="flex flex-wrap items-center">
212+
{selectedValues.slice(0, maxCount).map((value) => {
213+
const option = options.find((o) => o.value === value);
214+
const IconComponent = option?.icon;
215+
return (
216+
<Badge
217+
key={value}
218+
className={cn(
219+
isAnimating ? "animate-bounce" : "",
220+
multiSelectVariants({ variant })
221+
)}
222+
style={{ animationDuration: `${animation}s` }}
223+
>
224+
{IconComponent && (
225+
<IconComponent className="h-4 w-4 mr-2" />
226+
)}
227+
{option?.label}
228+
<XCircle
229+
className="ml-2 h-4 w-4 cursor-pointer"
230+
onClick={(event) => {
231+
event.stopPropagation();
232+
toggleOption(value);
233+
}}
234+
/>
235+
</Badge>
236+
);
237+
})}
238+
{selectedValues.length > maxCount && (
239+
<Badge
240+
className={cn(
241+
"bg-transparent text-foreground border-foreground/1 hover:bg-transparent",
242+
isAnimating ? "animate-bounce" : "",
243+
multiSelectVariants({ variant })
244+
)}
245+
style={{ animationDuration: `${animation}s` }}
246+
>
247+
{`+ ${selectedValues.length - maxCount} more`}
248+
<XCircle
249+
className="ml-2 h-4 w-4 cursor-pointer"
250+
onClick={(event) => {
251+
event.stopPropagation();
252+
clearExtraOptions();
253+
}}
254+
/>
255+
</Badge>
256+
)}
257+
</div>
258+
<div className="flex items-center justify-between">
259+
<XIcon
260+
className="h-4 mx-2 cursor-pointer text-muted-foreground"
261+
onClick={(event) => {
262+
event.stopPropagation();
263+
handleClear();
264+
}}
265+
/>
266+
<Separator
267+
orientation="vertical"
268+
className="flex min-h-6 h-full"
269+
/>
270+
<ChevronDown className="h-4 mx-2 cursor-pointer text-muted-foreground" />
271+
</div>
272+
</div>
273+
) : (
274+
<div className="flex items-center justify-between w-full mx-auto">
275+
<span className="text-sm text-muted-foreground mx-3">
276+
{placeholder}
277+
</span>
278+
<ChevronDown className="h-4 cursor-pointer text-muted-foreground mx-2" />
279+
</div>
280+
)}
281+
</Button>
282+
</PopoverTrigger>
283+
<PopoverContent
284+
className="w-auto p-0"
285+
align="start"
286+
onEscapeKeyDown={() => setIsPopoverOpen(false)}
287+
>
288+
<Command>
289+
<CommandInput
290+
placeholder="Search..."
291+
onKeyDown={handleInputKeyDown}
292+
/>
293+
<CommandList>
294+
<CommandEmpty>No results found.</CommandEmpty>
295+
<CommandGroup>
296+
<CommandItem
297+
key="all"
298+
onSelect={toggleAll}
299+
className="cursor-pointer"
300+
>
301+
<div
302+
className={cn(
303+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
304+
selectedValues.length === options.length
305+
? "bg-primary text-primary-foreground"
306+
: "opacity-50 [&_svg]:invisible"
307+
)}
308+
>
309+
<CheckIcon className="h-4 w-4" />
310+
</div>
311+
<span>(Select All)</span>
312+
</CommandItem>
313+
{options.map((option) => {
314+
const isSelected = selectedValues.includes(option.value);
315+
return (
316+
<CommandItem
317+
key={option.value}
318+
onSelect={() => toggleOption(option.value)}
319+
className="cursor-pointer"
320+
>
321+
<div
322+
className={cn(
323+
"mr-2 flex h-4 w-4 items-center justify-center rounded-sm border border-primary",
324+
isSelected
325+
? "bg-primary text-primary-foreground"
326+
: "opacity-50 [&_svg]:invisible"
327+
)}
328+
>
329+
<CheckIcon className="h-4 w-4" />
330+
</div>
331+
{option.icon && (
332+
<option.icon className="mr-2 h-4 w-4 text-muted-foreground" />
333+
)}
334+
<span>{option.label}</span>
335+
</CommandItem>
336+
);
337+
})}
338+
</CommandGroup>
339+
<CommandSeparator />
340+
<CommandGroup>
341+
<div className="flex items-center justify-between">
342+
{selectedValues.length > 0 && (
343+
<>
344+
<CommandItem
345+
onSelect={handleClear}
346+
className="flex-1 justify-center cursor-pointer"
347+
>
348+
Clear
349+
</CommandItem>
350+
<Separator
351+
orientation="vertical"
352+
className="flex min-h-6 h-full"
353+
/>
354+
</>
355+
)}
356+
<CommandItem
357+
onSelect={() => setIsPopoverOpen(false)}
358+
className="flex-1 justify-center cursor-pointer max-w-full"
359+
>
360+
Close
361+
</CommandItem>
362+
</div>
363+
</CommandGroup>
364+
</CommandList>
365+
</Command>
366+
</PopoverContent>
367+
{animation > 0 && selectedValues.length > 0 && (
368+
<WandSparkles
369+
className={cn(
370+
"cursor-pointer my-2 text-foreground bg-background w-3 h-3",
371+
isAnimating ? "" : "text-muted-foreground"
372+
)}
373+
onClick={() => setIsAnimating(!isAnimating)}
374+
/>
375+
)}
376+
</Popover>
377+
);
378+
}
379+
);
380+
381+
MultiSelect.displayName = "MultiSelect";

0 commit comments

Comments
 (0)