SearchPicker.tsx 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123
  1. 'use client'
  2. import { Check, ChevronDown } from 'lucide-react'
  3. import { useState } from 'react'
  4. import { Button } from '@/components/ui/button'
  5. import {
  6. Command,
  7. CommandEmpty,
  8. CommandGroup,
  9. CommandInput,
  10. CommandItem,
  11. CommandList,
  12. } from '@/components/ui/command'
  13. import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
  14. import { cn } from '@/lib/utils'
  15. export interface PickerOption {
  16. value: string
  17. label: string
  18. /** Extra words the search matches, such as a currency's code. */
  19. keywords?: string
  20. }
  21. /**
  22. * Ranks an option for what is typed. Whole words first, so "UK" finds the
  23. * United Kingdom before Ukraine; then names starting with it, words starting
  24. * with it, and anything containing it. No fuzzy matching: "UK" matching
  25. * Uzbekistan letter by letter only buries the answer.
  26. */
  27. export function pickerScore(value: string, search: string): number {
  28. const q = search.trim().toLowerCase()
  29. if (!q) return 1
  30. const text = value.toLowerCase()
  31. const words = text.split(/[\s,()/-]+/)
  32. if (words.includes(q)) return 1
  33. if (text.startsWith(q)) return 0.9
  34. if (words.some((w) => w.startsWith(q))) return 0.7
  35. if (text.includes(q)) return 0.4
  36. return 0
  37. }
  38. /** A searchable list behind a button, for long lists: countries, currencies, zones. */
  39. export function SearchPicker({
  40. id,
  41. value,
  42. options,
  43. onChange,
  44. placeholder,
  45. searchPlaceholder,
  46. empty,
  47. icon,
  48. className,
  49. disabled,
  50. }: {
  51. id?: string
  52. value: string | null
  53. options: PickerOption[]
  54. onChange: (value: string) => void
  55. placeholder: string
  56. searchPlaceholder: string
  57. empty: string
  58. icon?: React.ReactNode
  59. className?: string
  60. disabled?: boolean
  61. }) {
  62. const [open, setOpen] = useState(false)
  63. const selected = options.find((o) => o.value === value)
  64. return (
  65. <Popover open={open} onOpenChange={setOpen}>
  66. <PopoverTrigger asChild>
  67. <Button
  68. id={id}
  69. type="button"
  70. disabled={disabled}
  71. variant="outline"
  72. role="combobox"
  73. aria-expanded={open}
  74. className={cn(
  75. 'h-11 w-full justify-between bg-background px-3.5 text-[15px] font-normal',
  76. className
  77. )}
  78. >
  79. <span className="flex min-w-0 items-center gap-2.5">
  80. {icon}
  81. <span className={cn('truncate', !selected && 'text-muted-foreground')}>
  82. {selected?.label ?? placeholder}
  83. </span>
  84. </span>
  85. <ChevronDown className="h-4 w-4 shrink-0 opacity-60" aria-hidden="true" />
  86. </Button>
  87. </PopoverTrigger>
  88. <PopoverContent className="w-[--radix-popover-trigger-width] min-w-64 p-0" align="start">
  89. <Command filter={pickerScore}>
  90. <CommandInput placeholder={searchPlaceholder} />
  91. <CommandList>
  92. <CommandEmpty>{empty}</CommandEmpty>
  93. <CommandGroup>
  94. {options.map((option) => (
  95. <CommandItem
  96. key={option.value}
  97. value={`${option.label} ${option.keywords ?? ''} ${option.value}`}
  98. onSelect={() => {
  99. onChange(option.value)
  100. setOpen(false)
  101. }}
  102. >
  103. <Check
  104. className={cn(
  105. 'mr-2 h-4 w-4',
  106. value === option.value ? 'opacity-100' : 'opacity-0'
  107. )}
  108. aria-hidden="true"
  109. />
  110. {option.label}
  111. </CommandItem>
  112. ))}
  113. </CommandGroup>
  114. </CommandList>
  115. </Command>
  116. </PopoverContent>
  117. </Popover>
  118. )
  119. }