diff --git a/src/components/ui/alert.tsx b/src/components/ui/alert.tsx index cc096f5..7a55875 100644 --- a/src/components/ui/alert.tsx +++ b/src/components/ui/alert.tsx @@ -9,6 +9,8 @@ const alertVariants = cva( variants: { variant: { default: "bg-background text-foreground", + primary: "bg-primary/25 border-primary/50", + warning: "bg-amber-600/25 dark:bg-amber-400/25 border-amber-600/50 dark:border-amber-400/50", destructive: "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive", }, }, @@ -35,7 +37,7 @@ const AlertTitle = React.forwardRef (
), @@ -53,4 +55,4 @@ const AlertDescription = React.forwardRef, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return ( +
+ ) +} + +export { Badge, badgeVariants } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 762e2cd..48b56df 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -18,7 +18,7 @@ const buttonVariants = cva( "outline-destructive": "border border-input bg-background text-destructive stroke-destructive shadow-sm hover:bg-destructive hover:text-destructive-foreground hover:stroke-destructive-foreground", "ghost-destructive": - "stroke-destructive hover:bg-destructive hover:text-destructive-foreground hover:stroke-destructive-foreground", + "stroke-destructive text-destructive hover:bg-destructive dark:hover:bg-destructive/20 hover:text-destructive-foreground hover:stroke-destructive-foreground", "outline": "border border-input bg-background stroke-foreground shadow-sm hover:bg-accent hover:text-accent-foreground hover:stroke-accent-foreground", "secondary": @@ -108,7 +108,7 @@ const LoadingButton = React.forwardRef( duration: 0.3, ease: "easeInOut", }} - className={cn("relative inline-flex items-center justify-center", className)} + className={cn("relative inline-flex items-center justify-center gap-2", className)} > {children} diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..07680df --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,30 @@ +"use client" + +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "~/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/components/ui/combobox.responsive.tsx b/src/components/ui/combobox.responsive.tsx new file mode 100644 index 0000000..25cc430 --- /dev/null +++ b/src/components/ui/combobox.responsive.tsx @@ -0,0 +1,292 @@ +"use client"; + +import { useVirtualizer } from "@tanstack/react-virtual"; +import * as React from "react"; + +import { useResponsive } from "~/context/responsiveContext"; +import { cn } from "~/lib/utils"; + +import { Button, ButtonProps } from "./button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "./command"; +import { Drawer, DrawerContent, DrawerTrigger } from "./drawer"; +import Icon from "./icon"; +import { Popover, PopoverContent, PopoverTrigger } from "./popover"; + +interface ResponsiveComboboxRootProps extends React.PropsWithChildren { + modal?: boolean; +} +interface ResponsiveComboboxTriggerProps extends React.PropsWithChildren { + asChild?: true; + className?: string; + placeholder?: string; + variant?: ButtonProps["variant"]; + size?: ButtonProps["size"]; +} +interface ResponsiveComboboxContentProps extends React.PropsWithChildren { + cmdk?: { + placeholder?: string; + emptyMessage?: React.ReactNode; + }; +} +interface ResponsiveComboboxContextProps { + items: { + label: React.ReactNode; + value: string; + }[]; + selected?: string; + onSelect: (value: string) => void; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} +interface ResponsiveComboboxProviderProps extends React.PropsWithChildren { + items: { + label: React.ReactNode; + value: string; + }[]; + defaultSelected?: string; + open?: boolean; + onOpenChange?: (open: boolean) => void; +} + +const ResponsiveComboboxContext = React.createContext({ + items: [], + selected: "", + onSelect: () => { + void 0; + }, + open: false, + onOpenChange: () => { + void false; + }, +}); +const ResponsiveCombobox = ( + props: React.PropsWithChildren< + ResponsiveComboboxProviderProps & { + comboboxProps?: ResponsiveComboboxRootProps; + } + >, +) => { + const { isDesktop } = useResponsive(); + const Component = React.useMemo(() => (isDesktop ? Popover : Drawer), [isDesktop]); + const items = React.useMemo(() => props.items, [props.items]); + const [selected, setSelected] = React.useState(props.defaultSelected ?? undefined); + const [open, setOpen] = React.useState(props.open ?? false); + + const onSelect = React.useCallback((value: string) => { + setSelected(selected === value ? undefined : value); + setOpen(false); + }, []); + const onOpenChange = React.useCallback((open: boolean) => { + setOpen(open); + props.onOpenChange?.(open); + }, []); + + return ( + + + {props.children} + + + ); +}; +const useProvider = () => { + const context = React.useContext(ResponsiveComboboxContext); + if (!context) { + throw new Error("Combobox Provider is not found."); + } + return context; +}; + +const ResponsiveComboboxTrigger = (props: ResponsiveComboboxTriggerProps) => { + const { children: _children, className, placeholder, variant = "outline", size, ...rest } = props; + const { selected, items, open } = useProvider(); + const { isDesktop } = useResponsive(); + const Component = React.useMemo(() => (isDesktop ? PopoverTrigger : DrawerTrigger), [isDesktop]); + + return ( + + + + ); +}; +const ResponsiveComboboxContent = (props: ResponsiveComboboxContentProps) => { + const { cmdk, children: _children, ...rest } = props; + const { items, selected, onSelect } = useProvider(); + const { isDesktop } = useResponsive(); + const Component = React.useMemo(() => (isDesktop ? PopoverContent : DrawerContent), [isDesktop]); + + /** + * https://github.com/oaarnikoivu/shadcn-virtualized-combobox + */ + + const [filteredOptions, setFilteredOptions] = React.useState(items); + const [focusedIndex, setFocusedIndex] = React.useState(0); + const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false); + + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredOptions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + }); + const virtualOptions = virtualizer.getVirtualItems(); + + const scrollToIndex = (index: number) => { + virtualizer.scrollToIndex(index, { + align: "center", + }); + }; + const handleSearch = (search: string) => { + setIsKeyboardNavActive(false); + setFilteredOptions(items.filter((item) => item.value.toLowerCase().includes(search.toLowerCase() ?? []))); + }; + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": + event.preventDefault(); + setIsKeyboardNavActive(true); + setFocusedIndex((prev) => { + const newIndex = prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1); + scrollToIndex(newIndex); + return newIndex; + }); + break; + case "ArrowUp": + event.preventDefault(); + setIsKeyboardNavActive(true); + setFocusedIndex((prev) => { + const newIndex = prev === -1 ? 0 : Math.max(prev - 1, 0); + scrollToIndex(newIndex); + return newIndex; + }); + break; + case "Enter": + event.preventDefault(); + if (filteredOptions[focusedIndex]) { + onSelect(filteredOptions[focusedIndex].value); + } + break; + default: + break; + } + }; + + React.useEffect(() => { + if (selected) { + const option = filteredOptions.find((item) => item.value === selected); + if (option) { + const index = filteredOptions.indexOf(option); + setFocusedIndex(index); + virtualizer.scrollToIndex(index, { + align: "center", + }); + } + } + }, [selected, filteredOptions, virtualizer]); + + return ( + +
+ + + setIsKeyboardNavActive(false)} + onMouseMove={() => setIsKeyboardNavActive(false)} + > + {cmdk?.emptyMessage ?? "No item found."} + +
+ {virtualOptions.map((virtualOption) => ( + !isKeyboardNavActive && setFocusedIndex(virtualOption.index)} + onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)} + onSelect={onSelect} + > + + {filteredOptions[virtualOption.index]?.label} + + ))} + {/* {items.map((item) => ( + + + {item.label} + + ))} */} +
+
+
+
+
+
+ ); +}; + +export { ResponsiveCombobox, ResponsiveComboboxContent, ResponsiveComboboxTrigger }; diff --git a/src/components/ui/combobox.virtualized.tsx b/src/components/ui/combobox.virtualized.tsx new file mode 100644 index 0000000..66cff70 --- /dev/null +++ b/src/components/ui/combobox.virtualized.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useVirtualizer } from "@tanstack/react-virtual"; +import { Check, ChevronsUpDown } from "lucide-react"; +import * as React from "react"; + +import { Button } from "~/components/ui/button"; +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "~/components/ui/command"; +import { Popover, PopoverContent, PopoverTrigger } from "~/components/ui/popover"; + +import { cn } from "~/lib/utils"; + +type Option = { + value: string; + label: React.ReactNode; +}; + +interface VirtualizedCommandProps { + width: string; + maxWidth: string; + height: string; + options: Option[]; + placeholder: string; + selectedOption: string; + onSelectOption?: (option: string) => void; +} + +const VirtualizedCommand = ({ + width, + maxWidth, + height, + options, + placeholder, + selectedOption, + onSelectOption, +}: VirtualizedCommandProps) => { + const [filteredOptions, setFilteredOptions] = React.useState(options); + const [focusedIndex, setFocusedIndex] = React.useState(0); + const [isKeyboardNavActive, setIsKeyboardNavActive] = React.useState(false); + + const parentRef = React.useRef(null); + + const virtualizer = useVirtualizer({ + count: filteredOptions.length, + getScrollElement: () => parentRef.current, + estimateSize: () => 35, + }); + + const virtualOptions = virtualizer.getVirtualItems(); + + const scrollToIndex = (index: number) => { + virtualizer.scrollToIndex(index, { + align: "center", + }); + }; + + const handleSearch = (search: string) => { + setIsKeyboardNavActive(false); + setFilteredOptions(options.filter((option) => option.value.toLowerCase().includes(search.toLowerCase() ?? []))); + }; + + const handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.key) { + case "ArrowDown": { + event.preventDefault(); + setIsKeyboardNavActive(true); + setFocusedIndex((prev) => { + const newIndex = prev === -1 ? 0 : Math.min(prev + 1, filteredOptions.length - 1); + scrollToIndex(newIndex); + return newIndex; + }); + break; + } + case "ArrowUp": { + event.preventDefault(); + setIsKeyboardNavActive(true); + setFocusedIndex((prev) => { + const newIndex = prev === -1 ? filteredOptions.length - 1 : Math.max(prev - 1, 0); + scrollToIndex(newIndex); + return newIndex; + }); + break; + } + case "Enter": { + event.preventDefault(); + if (filteredOptions[focusedIndex]) { + onSelectOption?.(filteredOptions[focusedIndex].value); + } + break; + } + default: + break; + } + }; + + React.useEffect(() => { + if (selectedOption) { + const option = filteredOptions.find((option) => option.value === selectedOption); + if (option) { + const index = filteredOptions.indexOf(option); + setFocusedIndex(index); + virtualizer.scrollToIndex(index, { + align: "center", + }); + } + } + }, [selectedOption, filteredOptions, virtualizer]); + + return ( + + + setIsKeyboardNavActive(false)} + onMouseMove={() => setIsKeyboardNavActive(false)} + > + No item found. + +
+ {virtualOptions.map((virtualOption) => ( + !isKeyboardNavActive && setFocusedIndex(virtualOption.index)} + onMouseLeave={() => !isKeyboardNavActive && setFocusedIndex(-1)} + onSelect={onSelectOption} + > + + {filteredOptions[virtualOption.index]?.label} + + ))} +
+
+
+
+ ); +}; + +interface VirtualizedComboboxProps { + options: Option[]; + searchPlaceholder?: string; + width?: string; + minWidth?: string; + maxWidth?: string; + height?: string; + selectedOption?: string; + onSelectOption?: (option: string) => void; +} + +export function VirtualizedCombobox({ + options, + searchPlaceholder = "Search items...", + width = "100%", + minWidth = "200px", + maxWidth = "400px", + height = "400px", + selectedOption = "", + onSelectOption, +}: VirtualizedComboboxProps) { + const [open, setOpen] = React.useState(false); + // const [selectedOption, setSelectedOption] = React.useState(""); + + return ( + + + + + + { + onSelectOption?.(currentValue); + setOpen(false); + }} + /> + + + ); +} diff --git a/src/components/ui/dialog.responsive.tsx b/src/components/ui/dialog.responsive.tsx index ef93d0a..346a72b 100644 --- a/src/components/ui/dialog.responsive.tsx +++ b/src/components/ui/dialog.responsive.tsx @@ -34,6 +34,7 @@ import { interface ResponsiveDialogRootProps extends React.PropsWithChildren { open?: boolean; onOpenChange?: (open: boolean) => void; + modal?: boolean; } interface ResponsiveDialogProps extends React.PropsWithChildren { asChild?: true; @@ -47,7 +48,6 @@ const ResponsiveDialog = (props: ResponsiveDialogRootProps) => { const { ...rest } = props; const { isDesktop } = useResponsive(); const Component = React.useMemo(() => (isDesktop ? Dialog : Drawer), [isDesktop]); - return ; }; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index 0f54cdb..8d68121 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -38,7 +38,7 @@ const DialogContent = React.forwardRef< ) => ( +const Drawer = ({ shouldScaleBackground = false, ...props }: React.ComponentProps) => ( void; + modal?: boolean; } interface ResponsiveDropdownTriggerProps extends React.PropsWithChildren { asChild?: true; @@ -54,6 +55,7 @@ interface ResponsiveDropdownMenuItemProps extends React.PropsWithChildren { selected?: boolean; onSelect?: () => void; closeOnSelect?: boolean; + disabled?: boolean; } interface ResponsiveDropdownSeparatorProps extends React.PropsWithChildren { asChild?: true; @@ -113,7 +115,7 @@ const ResponsiveDropdownMenuContent = (props: ResponsiveDropdownContentProps) => return Component; }; const ResponsiveDropdownMenuItem = (props: ResponsiveDropdownMenuItemProps) => { - const { selected, onSelect, closeOnSelect, ...rest } = props; + const { selected, onSelect, closeOnSelect, disabled, ...rest } = props; const { isDesktop } = useResponsive(); const loading = useLoading(); const Wrapper = React.useCallback>( @@ -127,6 +129,7 @@ const ResponsiveDropdownMenuItem = (props: ResponsiveDropdownMenuItemProps) => { isDesktop ? ( ) : ( @@ -134,13 +137,13 @@ const ResponsiveDropdownMenuItem = (props: ResponsiveDropdownMenuItemProps) => { + )} +
); }); FormLabel.displayName = "FormLabel"; @@ -121,7 +155,7 @@ const FormDescription = React.forwardRef ); @@ -152,4 +186,4 @@ const FormMessage = React.forwardRef @@ -34,7 +34,7 @@ export default function Icon({ name, className, hideWrapper, wrapperProps, ...pr {...restWrapperProps} > diff --git a/src/components/ui/input.tsx b/src/components/ui/input.tsx index c630171..27004f0 100644 --- a/src/components/ui/input.tsx +++ b/src/components/ui/input.tsx @@ -7,8 +7,9 @@ const Input = React.forwardRef>( return ( )); diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index fb1d52f..513da20 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -36,7 +36,7 @@ const ScrollBar = React.forwardRef< )} {...props} > - + )); ScrollBar.displayName = ScrollAreaPrimitive.ScrollAreaScrollbar.displayName; diff --git a/src/components/ui/switch.tsx b/src/components/ui/switch.tsx new file mode 100644 index 0000000..3bd8058 --- /dev/null +++ b/src/components/ui/switch.tsx @@ -0,0 +1,29 @@ +"use client" + +import * as React from "react" +import * as SwitchPrimitives from "@radix-ui/react-switch" + +import { cn } from "~/lib/utils" + +const Switch = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)) +Switch.displayName = SwitchPrimitives.Root.displayName + +export { Switch } diff --git a/src/components/ui/use-toast.ts b/src/components/ui/use-toast.ts index 84c8ad7..d5508e0 100644 --- a/src/components/ui/use-toast.ts +++ b/src/components/ui/use-toast.ts @@ -15,6 +15,7 @@ type ToasterToast = ToastProps & { action?: ToastActionElement; }; +// eslint-disable-next-line @typescript-eslint/no-unused-vars const actionTypes = { ADD_TOAST: "ADD_TOAST", UPDATE_TOAST: "UPDATE_TOAST", @@ -186,4 +187,4 @@ function useToast() { }; } -export { useToast, toast }; +export { toast, useToast };