combobox.tsx 3.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143
  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. 'data-testid'?: string
  34. }
  35. // 将 Command 逻辑封装为独立组件,确保 options 更新时重新渲染
  36. interface ComboboxCommandProps {
  37. options: ComboboxOption[]
  38. value?: string
  39. onValueChange?: (value: string) => void
  40. onSearchChange?: (search: string) => void
  41. searchPlaceholder?: string
  42. emptyMessage?: string
  43. onClose: () => void
  44. }
  45. function ComboboxCommand({
  46. options,
  47. value,
  48. onValueChange,
  49. onSearchChange,
  50. searchPlaceholder = "搜索...",
  51. emptyMessage = "未找到匹配项",
  52. onClose,
  53. }: ComboboxCommandProps) {
  54. console.debug('ComboboxCommand options', options)
  55. return (
  56. <Command shouldFilter={false}>
  57. <CommandInput
  58. placeholder={searchPlaceholder}
  59. onValueChange={onSearchChange}
  60. />
  61. <CommandList>
  62. <CommandEmpty>{emptyMessage}</CommandEmpty>
  63. <CommandGroup>
  64. {options.map((option) => (
  65. <CommandItem
  66. key={option.value}
  67. value={option.value}
  68. onSelect={(currentValue) => {
  69. onValueChange?.(currentValue === value ? "" : currentValue)
  70. onClose()
  71. }}
  72. >
  73. <CheckIcon
  74. className={cn(
  75. "mr-2 h-4 w-4",
  76. value === option.value ? "opacity-100" : "opacity-0"
  77. )}
  78. />
  79. {option.label}
  80. </CommandItem>
  81. ))}
  82. </CommandGroup>
  83. </CommandList>
  84. </Command>
  85. )
  86. }
  87. export function Combobox({
  88. options,
  89. value,
  90. onValueChange,
  91. onSearchChange,
  92. placeholder = "请选择...",
  93. searchPlaceholder = "搜索...",
  94. emptyMessage = "未找到匹配项",
  95. className,
  96. disabled = false,
  97. "data-testid": dataTestId,
  98. }: ComboboxProps) {
  99. const [open, setOpen] = React.useState(false)
  100. const handleClose = () => {
  101. setOpen(false)
  102. }
  103. return (
  104. <Popover open={open} onOpenChange={setOpen}>
  105. <PopoverTrigger asChild>
  106. <Button
  107. variant="outline"
  108. role="combobox"
  109. aria-expanded={open}
  110. className={cn("w-full justify-between", className)}
  111. disabled={disabled}
  112. data-testid={dataTestId}
  113. >
  114. {value
  115. ? options.find((option) => option.value === value)?.label
  116. : placeholder}
  117. <ChevronsUpDownIcon className="ml-2 h-4 w-4 shrink-0 opacity-50" />
  118. </Button>
  119. </PopoverTrigger>
  120. <PopoverContent className="w-full p-0" align="start">
  121. <ComboboxCommand
  122. options={options}
  123. value={value}
  124. onValueChange={onValueChange}
  125. onSearchChange={onSearchChange}
  126. searchPlaceholder={searchPlaceholder}
  127. emptyMessage={emptyMessage}
  128. onClose={handleClose}
  129. />
  130. </PopoverContent>
  131. </Popover>
  132. )
  133. }