feat(setup): enhance provider selection with dropdown menu and keyboard accessibility (#71)
This commit is contained in:
@@ -689,6 +689,8 @@ function ProviderContent({
|
|||||||
const [selectedProviderConfigId, setSelectedProviderConfigId] = useState<string | null>(null);
|
const [selectedProviderConfigId, setSelectedProviderConfigId] = useState<string | null>(null);
|
||||||
const [baseUrl, setBaseUrl] = useState('');
|
const [baseUrl, setBaseUrl] = useState('');
|
||||||
const [modelId, setModelId] = useState('');
|
const [modelId, setModelId] = useState('');
|
||||||
|
const [providerMenuOpen, setProviderMenuOpen] = useState(false);
|
||||||
|
const providerMenuRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// On mount, try to restore previously configured provider
|
// On mount, try to restore previously configured provider
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -764,7 +766,33 @@ function ProviderContent({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [onApiKeyChange, selectedProvider, providers]);
|
}, [onApiKeyChange, selectedProvider, providers]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!providerMenuOpen) return;
|
||||||
|
|
||||||
|
const handlePointerDown = (event: MouseEvent) => {
|
||||||
|
if (providerMenuRef.current && !providerMenuRef.current.contains(event.target as Node)) {
|
||||||
|
setProviderMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEscape = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') {
|
||||||
|
setProviderMenuOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.addEventListener('mousedown', handlePointerDown);
|
||||||
|
document.addEventListener('keydown', handleEscape);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', handlePointerDown);
|
||||||
|
document.removeEventListener('keydown', handleEscape);
|
||||||
|
};
|
||||||
|
}, [providerMenuOpen]);
|
||||||
|
|
||||||
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
|
const selectedProviderData = providers.find((p) => p.id === selectedProvider);
|
||||||
|
const selectedProviderIconUrl = selectedProviderData
|
||||||
|
? getProviderIconUrl(selectedProviderData.id)
|
||||||
|
: undefined;
|
||||||
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
|
const showBaseUrlField = selectedProviderData?.showBaseUrl ?? false;
|
||||||
const showModelIdField = selectedProviderData?.showModelId ?? false;
|
const showModelIdField = selectedProviderData?.showModelId ?? false;
|
||||||
const requiresKey = selectedProviderData?.requiresApiKey ?? false;
|
const requiresKey = selectedProviderData?.requiresApiKey ?? false;
|
||||||
@@ -855,37 +883,95 @@ function ProviderContent({
|
|||||||
&& (requiresKey ? apiKey.length > 0 : true)
|
&& (requiresKey ? apiKey.length > 0 : true)
|
||||||
&& (showModelIdField ? modelId.trim().length > 0 : true);
|
&& (showModelIdField ? modelId.trim().length > 0 : true);
|
||||||
|
|
||||||
|
const handleSelectProvider = (providerId: string) => {
|
||||||
|
onSelectProvider(providerId);
|
||||||
|
setSelectedProviderConfigId(null);
|
||||||
|
onConfiguredChange(false);
|
||||||
|
onApiKeyChange('');
|
||||||
|
setKeyValid(null);
|
||||||
|
setProviderMenuOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Provider selector — dropdown */}
|
{/* Provider selector — dropdown */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="provider">{t('provider.label')}</Label>
|
<Label>{t('provider.label')}</Label>
|
||||||
<div className="relative">
|
<div className="relative" ref={providerMenuRef}>
|
||||||
<select
|
<button
|
||||||
id="provider"
|
type="button"
|
||||||
value={selectedProvider || ''}
|
aria-haspopup="listbox"
|
||||||
onChange={(e) => {
|
aria-expanded={providerMenuOpen}
|
||||||
const val = e.target.value || null;
|
onClick={() => setProviderMenuOpen((open) => !open)}
|
||||||
onSelectProvider(val);
|
|
||||||
setSelectedProviderConfigId(null);
|
|
||||||
onConfiguredChange(false);
|
|
||||||
onApiKeyChange('');
|
|
||||||
setKeyValid(null);
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'appearance-none rounded-md border border-input bg-background px-3 py-2 pr-8',
|
'w-full rounded-md border border-input bg-background px-3 py-2 text-sm',
|
||||||
'w-full text-sm text-foreground cursor-pointer',
|
'flex items-center justify-between gap-2',
|
||||||
'focus:outline-none focus:ring-2 focus:ring-ring',
|
'focus:outline-none focus:ring-2 focus:ring-ring'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<option value="" disabled className="text-muted-foreground">{t('provider.selectPlaceholder')}</option>
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{providers.map((p) => (
|
{selectedProvider && selectedProviderData ? (
|
||||||
<option key={p.id} value={p.id} className="text-foreground">
|
selectedProviderIconUrl ? (
|
||||||
{p.icon} {p.name}{p.model ? ` — ${p.model}` : ''}
|
<img
|
||||||
</option>
|
src={selectedProviderIconUrl}
|
||||||
))}
|
alt={selectedProviderData.name}
|
||||||
</select>
|
className={cn('h-4 w-4 shrink-0', shouldInvertInDark(selectedProviderData.id) && 'dark:invert')}
|
||||||
<ChevronDown className="absolute right-2 top-1/2 -translate-y-1/2 h-3.5 w-3.5 text-muted-foreground pointer-events-none" />
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm leading-none shrink-0">{selectedProviderData.icon}</span>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-muted-foreground shrink-0">—</span>
|
||||||
|
)}
|
||||||
|
<span className={cn('truncate text-left', !selectedProvider && 'text-muted-foreground')}>
|
||||||
|
{selectedProviderData
|
||||||
|
? `${selectedProviderData.name}${selectedProviderData.model ? ` — ${selectedProviderData.model}` : ''}`
|
||||||
|
: t('provider.selectPlaceholder')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className={cn('h-3.5 w-3.5 text-muted-foreground shrink-0 transition-transform', providerMenuOpen && 'rotate-180')} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{providerMenuOpen && (
|
||||||
|
<div
|
||||||
|
role="listbox"
|
||||||
|
className="absolute z-20 mt-1 w-full rounded-md border border-border bg-popover shadow-md max-h-64 overflow-auto"
|
||||||
|
>
|
||||||
|
{providers.map((p) => {
|
||||||
|
const iconUrl = getProviderIconUrl(p.id);
|
||||||
|
const isSelected = selectedProvider === p.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={p.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={isSelected}
|
||||||
|
onClick={() => handleSelectProvider(p.id)}
|
||||||
|
className={cn(
|
||||||
|
'w-full px-3 py-2 text-left text-sm flex items-center justify-between gap-2',
|
||||||
|
'hover:bg-accent transition-colors',
|
||||||
|
isSelected && 'bg-accent/60'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
{iconUrl ? (
|
||||||
|
<img
|
||||||
|
src={iconUrl}
|
||||||
|
alt={p.name}
|
||||||
|
className={cn('h-4 w-4 shrink-0', shouldInvertInDark(p.id) && 'dark:invert')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm leading-none shrink-0">{p.icon}</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{p.name}{p.model ? ` — ${p.model}` : ''}</span>
|
||||||
|
</div>
|
||||||
|
{isSelected && <Check className="h-4 w-4 text-primary shrink-0" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user