From e4fe7f05a6cf4ec6d1430ec59bf73cace83063f3 Mon Sep 17 00:00:00 2001 From: ronload <91997734+ronload@users.noreply.github.com> Date: Wed, 15 Apr 2026 00:29:33 +0800 Subject: [PATCH 1/4] refactor(form): use shadcn native combobox instead customize combobox --- src/components/road-combobox.tsx | 98 +++++----- src/components/ui/button.tsx | 14 +- src/components/ui/combobox.tsx | 297 ++++++++++++++++++++++++++++++ src/components/ui/input-group.tsx | 54 +++--- src/components/ui/input.tsx | 14 +- src/components/ui/textarea.tsx | 12 +- 6 files changed, 386 insertions(+), 103 deletions(-) create mode 100644 src/components/ui/combobox.tsx diff --git a/src/components/road-combobox.tsx b/src/components/road-combobox.tsx index da9801d..713e74c 100644 --- a/src/components/road-combobox.tsx +++ b/src/components/road-combobox.tsx @@ -1,21 +1,15 @@ "use client"; -import { useState } from "react"; import type { Road } from "@/lib/types"; import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { ChevronsUpDown, Loader2 } from "lucide-react"; + Combobox, + ComboboxContent, + ComboboxEmpty, + ComboboxInput, + ComboboxItem, + ComboboxList, +} from "@/components/ui/combobox"; +import { Loader2 } from "lucide-react"; interface RoadComboboxProps { roads: Road[]; @@ -32,54 +26,46 @@ export function RoadCombobox({ disabled, loading, }: RoadComboboxProps) { - const [open, setOpen] = useState(false); - return (
- - + + {"載入中..."} +
+ ) : ( + onChange(val ?? null)} + itemToStringLabel={(road) => road.name} + filter={(road, query) => { + const q = query.toLowerCase(); + return ( + road.name.toLowerCase().includes(q) || + road.en.toLowerCase().includes(q) + ); + }} > - {loading ? ( - - - {"載入中..."} - - ) : value ? ( - {value.name} - ) : ( - {"請選擇路/街..."} - )} - - - - - - - {"查無結果"} - - {roads.map((road) => ( - { - onChange(road); - setOpen(false); - }} - data-checked={value?.name === road.name ? true : undefined} - > - {road.name} - - ))} - - - - - + + + {"查無結果"} + + {(road: Road) => ( + + {road.name} + + )} + + + + )} ); } diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index cc44df6..09df753 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,7 +1,7 @@ -import { Button as ButtonPrimitive } from "@base-ui/react/button"; -import { cva, type VariantProps } from "class-variance-authority"; +import { Button as ButtonPrimitive } from "@base-ui/react/button" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils"; +import { cn } from "@/lib/utils" const buttonVariants = cva( "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", @@ -37,8 +37,8 @@ const buttonVariants = cva( variant: "default", size: "default", }, - }, -); + } +) function Button({ className, @@ -52,7 +52,7 @@ function Button({ className={cn(buttonVariants({ variant, size, className }))} {...props} /> - ); + ) } -export { Button, buttonVariants }; +export { Button, buttonVariants } diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx new file mode 100644 index 0000000..39e2b93 --- /dev/null +++ b/src/components/ui/combobox.tsx @@ -0,0 +1,297 @@ +"use client" + +import * as React from "react" +import { Combobox as ComboboxPrimitive } from "@base-ui/react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + InputGroup, + InputGroupAddon, + InputGroupButton, + InputGroupInput, +} from "@/components/ui/input-group" +import { ChevronDownIcon, XIcon, CheckIcon } from "lucide-react" + +const Combobox = ComboboxPrimitive.Root + +function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) { + return +} + +function ComboboxTrigger({ + className, + children, + ...props +}: ComboboxPrimitive.Trigger.Props) { + return ( + + {children} + + + ) +} + +function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) { + return ( + } + className={cn(className)} + {...props} + > + + + ) +} + +function ComboboxInput({ + className, + children, + disabled = false, + showTrigger = true, + showClear = false, + ...props +}: ComboboxPrimitive.Input.Props & { + showTrigger?: boolean + showClear?: boolean +}) { + return ( + + } + {...props} + /> + + {showTrigger && ( + } + data-slot="input-group-button" + className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent" + disabled={disabled} + /> + )} + {showClear && } + + {children} + + ) +} + +function ComboboxContent({ + className, + side = "bottom", + sideOffset = 6, + align = "start", + alignOffset = 0, + anchor, + ...props +}: ComboboxPrimitive.Popup.Props & + Pick< + ComboboxPrimitive.Positioner.Props, + "side" | "align" | "sideOffset" | "alignOffset" | "anchor" + >) { + return ( + + + + + + ) +} + +function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) { + return ( + + ) +} + +function ComboboxItem({ + className, + children, + ...props +}: ComboboxPrimitive.Item.Props) { + return ( + + {children} + + } + > + + + + ) +} + +function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) { + return ( + + ) +} + +function ComboboxLabel({ + className, + ...props +}: ComboboxPrimitive.GroupLabel.Props) { + return ( + + ) +} + +function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) { + return ( + + ) +} + +function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) { + return ( + + ) +} + +function ComboboxSeparator({ + className, + ...props +}: ComboboxPrimitive.Separator.Props) { + return ( + + ) +} + +function ComboboxChips({ + className, + ...props +}: React.ComponentPropsWithRef & + ComboboxPrimitive.Chips.Props) { + return ( + + ) +} + +function ComboboxChip({ + className, + children, + showRemove = true, + ...props +}: ComboboxPrimitive.Chip.Props & { + showRemove?: boolean +}) { + return ( + + {children} + {showRemove && ( + } + className="-ml-1 opacity-50 hover:opacity-100" + data-slot="combobox-chip-remove" + > + + + )} + + ) +} + +function ComboboxChipsInput({ + className, + ...props +}: ComboboxPrimitive.Input.Props) { + return ( + + ) +} + +function useComboboxAnchor() { + return React.useRef(null) +} + +export { + Combobox, + ComboboxInput, + ComboboxContent, + ComboboxList, + ComboboxItem, + ComboboxGroup, + ComboboxLabel, + ComboboxCollection, + ComboboxEmpty, + ComboboxSeparator, + ComboboxChips, + ComboboxChip, + ComboboxChipsInput, + ComboboxTrigger, + ComboboxValue, + useComboboxAnchor, +} diff --git a/src/components/ui/input-group.tsx b/src/components/ui/input-group.tsx index 989c2ec..da8f1dd 100644 --- a/src/components/ui/input-group.tsx +++ b/src/components/ui/input-group.tsx @@ -1,12 +1,12 @@ -"use client"; +"use client" -import * as React from "react"; -import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react" +import { cva, type VariantProps } from "class-variance-authority" -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Textarea } from "@/components/ui/textarea"; +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" function InputGroup({ className, ...props }: React.ComponentProps<"div">) { return ( @@ -14,12 +14,12 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) { data-slot="input-group" role="group" className={cn( - "group/input-group border-input has-disabled:bg-input/50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-destructive/20 dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 relative flex h-8 w-full min-w-0 items-center rounded-lg border transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-3 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", - className, + "group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5", + className )} {...props} /> - ); + ) } const inputGroupAddonVariants = cva( @@ -40,8 +40,8 @@ const inputGroupAddonVariants = cva( defaultVariants: { align: "inline-start", }, - }, -); + } +) function InputGroupAddon({ className, @@ -56,13 +56,13 @@ function InputGroupAddon({ className={cn(inputGroupAddonVariants({ align }), className)} onClick={(e) => { if ((e.target as HTMLElement).closest("button")) { - return; + return } - e.currentTarget.parentElement?.querySelector("input")?.focus(); + e.currentTarget.parentElement?.querySelector("input")?.focus() }} {...props} /> - ); + ) } const inputGroupButtonVariants = cva( @@ -80,8 +80,8 @@ const inputGroupButtonVariants = cva( defaultVariants: { size: "xs", }, - }, -); + } +) function InputGroupButton({ className, @@ -91,7 +91,7 @@ function InputGroupButton({ ...props }: Omit, "size" | "type"> & VariantProps & { - type?: "button" | "submit" | "reset"; + type?: "button" | "submit" | "reset" }) { return (