combobox.tsx 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140
  1. "use client"
  2. import * as React from "react"
  3. import { CheckIcon, ChevronsUpDownIcon } from "lucide-react"
  4. import { cn } from "@/client/lib/utils"
  5. import { Button } from "@/client/components/ui/button"
  6. import {
  7. Command,
  8. CommandEmpty,
  9. CommandGroup,
  10. CommandInput,
  11. CommandItem,
  12. CommandList,
  13. } from "@/client/components/ui/command"
  14. import {
  15. Popover,
  16. PopoverContent,
  17. PopoverTrigger,
  18. } from "@/client/components/ui/popover"
  19. export interface ComboboxOption {
  20. value: string
  21. label: string
  22. }
  23. interface ComboboxProps {
  24. options: ComboboxOption[]
  25. value?: string
  26. onValueChange?: (value: string) => void
  27. onSearchChange?: (search: string) => void
  28. placeholder?: string
  29. searchPlaceholder?: string
  30. emptyMessage?: string
  31. className?: string
  32. disabled?: boolean
  33. }
  34. // 将 Command 逻辑封装为独立组件,确保 options 更新时重新渲染
  35. interface ComboboxCommandProps {
  36. options: ComboboxOption[]
  37. value?: string
  38. onValueChange?: (value: string) => void
  39. onSearchChange?: (search: string) => void
  40. searchPlaceholder?: string
  41. emptyMessage?: string
  42. onClose: () => void
  43. }
  44. function ComboboxCommand({
  45. options,
  46. value,
  47. onValueChange,
  48. onSearchChange,
  49. searchPlaceholder = "搜索...",
  50. emptyMessage = "未找到匹配项",
  51. onClose,
  52. }: ComboboxCommandProps) {
  53. console.debug('ComboboxCommand options', options)
  54. return (
  55. <Command shouldFilter={false}>
  56. <CommandInput
  57. placeholder={searchPlaceholder}
  58. onValueChange={onSearchChange}
  59. />
  60. <CommandList>
  61. <CommandEmpty>{emptyMessage}</CommandEmpty>
  62. <CommandGroup>
  63. {options.map((option) => (
  64. <CommandItem
  65. key={option.value}
  66. value={option.value}
  67. onSelect={(currentValue) => {
  68. onValueChange?.(currentValue === value ? "" : currentValue)
  69. onClose()
  70. }}
  71. >
  72. <CheckIcon
  73. className={cn(
  74. "mr-2 h-4 w-4",
  75. value === option.value ? "opacity-100" : "opacity-0"
  76. )}
  77. />
  78. {option.label}
  79. </CommandItem>
  80. ))}
  81. </CommandGroup>
  82. </CommandList>
  83. </Command>
  84. )
  85. }
  86. export function Combobox({
  87. options,
  88. value,
  89. onValueChange,
  90. onSearchChange,
  91. placeholder = "请选择...",
  92. searchPlaceholder = "搜索...",
  93. emptyMessage = "未找到匹配项",
  94. className,
  95. disabled = false,
  96. }: ComboboxProps) {
  97. const [open, setOpen] = React.useState(false)
  98. const handleClose = () => {
  99. setOpen(false)
  100. }
  101. return (
  102. <Popover open={open} onOpenChange={setOpen}>
  103. <PopoverTrigger asChild>
  104. <Button
  105. variant="outline"
  106. role="combobox"
  107. aria-expanded={open}
  108. className={cn("w-full justify-between", className)}
  109. disabled={disabled}
  110. >
  111. {value
  112. ? options.find((option) => option.value === value)?.label
  113. : placeholder}
  114. <ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
  115. </Button>
  116. </PopoverTrigger>
  117. <PopoverContent className="w-full p-0" align="start">
  118. <ComboboxCommand
  119. options={options}
  120. value={value}
  121. onValueChange={onValueChange}
  122. onSearchChange={onSearchChange}
  123. searchPlaceholder={searchPlaceholder}
  124. emptyMessage={emptyMessage}
  125. onClose={handleClose}
  126. />
  127. </PopoverContent>
  128. </Popover>
  129. )
  130. }