aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/components/category-selector.tsx94
-rw-r--r--src/components/expense-form.tsx36
-rw-r--r--src/components/ui/command.tsx155
3 files changed, 254 insertions, 31 deletions
diff --git a/src/components/category-selector.tsx b/src/components/category-selector.tsx
new file mode 100644
index 0000000..adb6582
--- /dev/null
+++ b/src/components/category-selector.tsx
@@ -0,0 +1,94 @@
+import { ChevronDown } from 'lucide-react'
+
+import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
+import { Button } from '@/components/ui/button'
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+} from '@/components/ui/command'
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from '@/components/ui/popover'
+import { Category } from '@prisma/client'
+import { useState } from 'react'
+
+type Props = {
+ categories: Category[]
+ onValueChange: (categoryId: Category['id']) => void
+ defaultValue: Category['id']
+}
+
+export function CategorySelector({
+ categories,
+ onValueChange,
+ defaultValue,
+}: Props) {
+ const [open, setOpen] = useState(false)
+ const [value, setValue] = useState<number>(defaultValue)
+
+ const categoriesByGroup = categories.reduce<Record<string, Category[]>>(
+ (acc, category) => ({
+ ...acc,
+ [category.grouping]: [...(acc[category.grouping] ?? []), category],
+ }),
+ {},
+ )
+
+ const selectedCategory =
+ categories.find((category) => category.id === value) ?? categories[0]
+
+ return (
+ <Popover open={open} onOpenChange={setOpen}>
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={open}
+ className="flex w-full justify-between"
+ >
+ <div className="flex items-center gap-3">
+ <CategoryIcon category={selectedCategory} className="w-4 h-4" />
+ {selectedCategory.name}
+ </div>
+ <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
+ <PopoverContent className="p-0" align="start">
+ <Command>
+ <CommandInput placeholder="Search category..." />
+ <CommandEmpty>No category found.</CommandEmpty>
+ <div className="w-full max-h-[300px] overflow-y-auto">
+ {Object.entries(categoriesByGroup).map(
+ ([group, groupCategories], index) => (
+ <CommandGroup key={index} heading={group}>
+ {groupCategories.map((category) => (
+ <CommandItem
+ key={category.id}
+ value={`${category.id} ${category.grouping} ${category.name}`}
+ onSelect={(currentValue) => {
+ const id = Number(currentValue.split(' ')[0])
+ setValue(id)
+ onValueChange(id)
+ setOpen(false)
+ }}
+ >
+ <div className="flex items-center gap-3">
+ <CategoryIcon category={category} className="w-4 h-4" />
+ {category.name}
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ ),
+ )}
+ </div>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ )
+}
diff --git a/src/components/expense-form.tsx b/src/components/expense-form.tsx
index cfc749a..dfaaaa0 100644
--- a/src/components/expense-form.tsx
+++ b/src/components/expense-form.tsx
@@ -1,6 +1,6 @@
'use client'
-import { CategoryIcon } from '@/app/groups/[groupId]/expenses/category-icon'
import { AsyncButton } from '@/components/async-button'
+import { CategorySelector } from '@/components/category-selector'
import { SubmitButton } from '@/components/submit-button'
import { Button } from '@/components/ui/button'
import {
@@ -29,9 +29,7 @@ import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
- SelectGroup,
SelectItem,
- SelectLabel,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
@@ -227,35 +225,11 @@ export function ExpenseForm({
render={({ field }) => (
<FormItem className="order-3 sm:order-2">
<FormLabel>Category</FormLabel>
- <Select
+ <CategorySelector
+ categories={categories}
+ defaultValue={field.value}
onValueChange={field.onChange}
- defaultValue={field.value.toString()}
- >
- <SelectTrigger>
- <SelectValue placeholder="Select a category" />
- </SelectTrigger>
- <SelectContent>
- {Object.keys(categoriesByGroup).map((group) => (
- <SelectGroup key={group}>
- <SelectLabel className="-ml-6">{group}</SelectLabel>
- {categoriesByGroup[group].map((category) => (
- <SelectItem
- key={category.id.toString()}
- value={category.id.toString()}
- >
- <div className="flex items-center gap-3">
- <CategoryIcon
- category={category}
- className="w-4 h-4"
- />
- {category.name}
- </div>
- </SelectItem>
- ))}
- </SelectGroup>
- ))}
- </SelectContent>
- </Select>
+ />
<FormDescription>
Select the expense category.
</FormDescription>
diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx
new file mode 100644
index 0000000..17cc641
--- /dev/null
+++ b/src/components/ui/command.tsx
@@ -0,0 +1,155 @@
+"use client"
+
+import * as React from "react"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Command as CommandPrimitive } from "cmdk"
+import { Search } from "lucide-react"
+
+import { cn } from "@/lib/utils"
+import { Dialog, DialogContent } from "@/components/ui/dialog"
+
+const Command = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive
+ ref={ref}
+ className={cn(
+ "flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground",
+ className
+ )}
+ {...props}
+ />
+))
+Command.displayName = CommandPrimitive.displayName
+
+interface CommandDialogProps extends DialogProps {}
+
+const CommandDialog = ({ children, ...props }: CommandDialogProps) => {
+ return (
+ <Dialog {...props}>
+ <DialogContent className="overflow-hidden p-0 shadow-lg">
+ <Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
+ {children}
+ </Command>
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+const CommandInput = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Input>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
+>(({ className, ...props }, ref) => (
+ <div className="flex items-center border-b px-3" cmdk-input-wrapper="">
+ <Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
+ <CommandPrimitive.Input
+ ref={ref}
+ className={cn(
+ "flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50",
+ className
+ )}
+ {...props}
+ />
+ </div>
+))
+
+CommandInput.displayName = CommandPrimitive.Input.displayName
+
+const CommandList = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.List>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.List
+ ref={ref}
+ className={cn("max-h-[300px] overflow-y-auto overflow-x-hidden", className)}
+ {...props}
+ />
+))
+
+CommandList.displayName = CommandPrimitive.List.displayName
+
+const CommandEmpty = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Empty>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
+>((props, ref) => (
+ <CommandPrimitive.Empty
+ ref={ref}
+ className="py-6 text-center text-sm"
+ {...props}
+ />
+))
+
+CommandEmpty.displayName = CommandPrimitive.Empty.displayName
+
+const CommandGroup = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Group>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Group
+ ref={ref}
+ className={cn(
+ "overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground",
+ className
+ )}
+ {...props}
+ />
+))
+
+CommandGroup.displayName = CommandPrimitive.Group.displayName
+
+const CommandSeparator = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Separator>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Separator
+ ref={ref}
+ className={cn("-mx-1 h-px bg-border", className)}
+ {...props}
+ />
+))
+CommandSeparator.displayName = CommandPrimitive.Separator.displayName
+
+const CommandItem = React.forwardRef<
+ React.ElementRef<typeof CommandPrimitive.Item>,
+ React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
+>(({ className, ...props }, ref) => (
+ <CommandPrimitive.Item
+ ref={ref}
+ className={cn(
+ "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
+ className
+ )}
+ {...props}
+ />
+))
+
+CommandItem.displayName = CommandPrimitive.Item.displayName
+
+const CommandShortcut = ({
+ className,
+ ...props
+}: React.HTMLAttributes<HTMLSpanElement>) => {
+ return (
+ <span
+ className={cn(
+ "ml-auto text-xs tracking-widest text-muted-foreground",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+CommandShortcut.displayName = "CommandShortcut"
+
+export {
+ Command,
+ CommandDialog,
+ CommandInput,
+ CommandList,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+ CommandShortcut,
+ CommandSeparator,
+}