Files
Momento/memento-note/components/ui/combobox.tsx
Sepehr Ramezani dbd49d6fcb
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 16:14:07 +02:00

149 lines
4.9 KiB
TypeScript

'use client'
import * as React from 'react'
import { Check, ChevronDown, Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
interface ComboboxOption {
value: string
label: string
}
interface ComboboxProps {
options: ComboboxOption[]
value: string
onChange: (value: string) => void
placeholder?: string
searchPlaceholder?: string
emptyMessage?: string
disabled?: boolean
className?: string
/** When true, the search text can be submitted as a custom value not in the options list */
allowCustomValue?: boolean
}
export function Combobox({
options,
value,
onChange,
placeholder = 'Select...',
searchPlaceholder = 'Search...',
emptyMessage = 'No results found.',
disabled = false,
className,
allowCustomValue = false,
}: ComboboxProps) {
const [open, setOpen] = React.useState(false)
const [search, setSearch] = React.useState('')
const selectedLabel = options.find((o) => o.value === value)?.label
const filtered = React.useMemo(() => {
if (!search.trim()) return options
const q = search.toLowerCase()
return options.filter(
(o) =>
o.label.toLowerCase().includes(q) ||
o.value.toLowerCase().includes(q)
)
}, [options, search])
const handleSelect = (optionValue: string) => {
onChange(optionValue === value ? '' : optionValue)
setOpen(false)
setSearch('')
}
const handleConfirmCustom = () => {
if (allowCustomValue && search.trim()) {
onChange(search.trim())
setOpen(false)
setSearch('')
}
}
return (
<Popover open={open} onOpenChange={(v) => { setOpen(v); if (!v) setSearch('') }}>
<PopoverTrigger asChild>
<button
type="button"
role="combobox"
aria-expanded={open}
disabled={disabled}
className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'hover:bg-accent hover:text-accent-foreground transition-colors',
'disabled:cursor-not-allowed disabled:opacity-50',
!value && 'text-muted-foreground',
className
)}
>
<span className="truncate">{selectedLabel || placeholder}</span>
<ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</button>
</PopoverTrigger>
<PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
<div className="flex items-center border-b px-3">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<input
className="flex h-10 w-full bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground"
placeholder={searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleConfirmCustom() } }}
autoFocus
/>
</div>
<div className="max-h-60 overflow-y-auto p-1">
{filtered.length === 0 ? (
allowCustomValue && search.trim() ? (
<button
type="button"
onClick={handleConfirmCustom}
className="flex w-full cursor-pointer items-center rounded-sm px-2 py-1.5 text-sm hover:bg-accent hover:text-accent-foreground transition-colors"
>
<span className="text-muted-foreground mr-2">+</span>
<span>Use "<strong>{search.trim()}</strong>"</span>
</button>
) : (
<div className="py-6 text-center text-sm text-muted-foreground">
{emptyMessage}
</div>
)
) : (
filtered.map((option) => {
const isSelected = option.value === value
return (
<button
key={option.value}
type="button"
onClick={() => handleSelect(option.value)}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',
'hover:bg-accent hover:text-accent-foreground transition-colors',
isSelected && 'bg-accent'
)}
>
<Check
className={cn(
'mr-2 h-4 w-4 shrink-0',
isSelected ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="truncate">{option.label}</span>
</button>
)
})
)}
</div>
</PopoverContent>
</Popover>
)
}