feat: add autocomplete to settings search

- Show dropdown with top 8 matching settings while typing
- Search by key, name, translated name, description and category
- Display setting name and category in suggestions
- Keyboard navigation (arrows, Enter, Escape)
- On select: switch to correct section and filter by setting key
- Works on both desktop and mobile
This commit is contained in:
c0mrade
2026-02-02 09:17:51 +03:00
parent ead4606bb5
commit e5096d571f
2 changed files with 253 additions and 28 deletions

View File

@@ -1,23 +1,112 @@
import { useState, useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { SettingDefinition } from '../../api/adminSettings';
import { SearchIcon, CloseIcon } from './icons';
import { formatSettingKey } from './utils';
interface SettingsSearchProps {
searchQuery: string;
setSearchQuery: (query: string) => void;
resultsCount?: number;
allSettings?: SettingDefinition[];
onSelectSetting?: (setting: SettingDefinition) => void;
}
export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchProps) {
export function SettingsSearch({
searchQuery,
setSearchQuery,
allSettings,
onSelectSetting,
}: SettingsSearchProps) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
// Filter settings for autocomplete
const suggestions =
searchQuery.trim() && allSettings
? allSettings
.filter((s) => {
const q = searchQuery.toLowerCase().trim();
if (s.key.toLowerCase().includes(q)) return true;
if (s.name?.toLowerCase().includes(q)) return true;
const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true;
if (s.hint?.description?.toLowerCase().includes(q)) return true;
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true;
return false;
})
.slice(0, 8)
: [];
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
// Reset highlighted index when suggestions change
useEffect(() => {
setHighlightedIndex(0);
}, [suggestions.length, searchQuery]);
const handleKeyDown = (e: React.KeyboardEvent) => {
if (!isOpen || suggestions.length === 0) return;
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
setHighlightedIndex((i) => (i + 1) % suggestions.length);
break;
case 'ArrowUp':
e.preventDefault();
setHighlightedIndex((i) => (i - 1 + suggestions.length) % suggestions.length);
break;
case 'Enter':
e.preventDefault();
if (suggestions[highlightedIndex]) {
handleSelect(suggestions[highlightedIndex]);
}
break;
case 'Escape':
setIsOpen(false);
inputRef.current?.blur();
break;
}
};
const handleSelect = (setting: SettingDefinition) => {
setIsOpen(false);
setSearchQuery(setting.name || setting.key);
onSelectSetting?.(setting);
};
const getSettingDisplayName = (setting: SettingDefinition) => {
const formattedKey = formatSettingKey(setting.name || setting.key);
return t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
};
return (
<>
{/* Search - desktop */}
<div className="relative hidden sm:block">
<div ref={containerRef} className="relative hidden sm:block">
<input
ref={inputRef}
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
onKeyDown={handleKeyDown}
placeholder={t('admin.settings.searchPlaceholder')}
className="w-48 rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none lg:w-64"
/>
@@ -26,29 +115,108 @@ export function SettingsSearch({ searchQuery, setSearchQuery }: SettingsSearchPr
</div>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
onClick={() => {
setSearchQuery('');
setIsOpen(false);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
>
<CloseIcon />
</button>
)}
{/* Autocomplete dropdown */}
{isOpen && suggestions.length > 0 && (
<div className="absolute right-0 top-full z-50 mt-1 max-h-80 w-80 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{suggestions.map((setting, index) => (
<button
key={setting.key}
onClick={() => handleSelect(setting)}
onMouseEnter={() => setHighlightedIndex(index)}
className={`flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors ${
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50'
}`}
>
<span className="truncate text-sm font-medium text-dark-100">
{getSettingDisplayName(setting)}
</span>
<span className="truncate text-xs text-dark-500">
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
</span>
</button>
))}
</div>
)}
</div>
</>
);
}
export function SettingsSearchMobile({
searchQuery,
setSearchQuery,
allSettings,
onSelectSetting,
}: Omit<SettingsSearchProps, 'resultsCount'>) {
const { t } = useTranslation();
const [isOpen, setIsOpen] = useState(false);
const [highlightedIndex, setHighlightedIndex] = useState(0);
const containerRef = useRef<HTMLDivElement>(null);
// Filter settings for autocomplete
const suggestions =
searchQuery.trim() && allSettings
? allSettings
.filter((s) => {
const q = searchQuery.toLowerCase().trim();
if (s.key.toLowerCase().includes(q)) return true;
if (s.name?.toLowerCase().includes(q)) return true;
const formattedKey = formatSettingKey(s.name || s.key);
const translatedName = t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
if (translatedName.toLowerCase().includes(q)) return true;
if (s.hint?.description?.toLowerCase().includes(q)) return true;
const categoryLabel = t(`admin.settings.categories.${s.category.key}`, s.category.key);
if (categoryLabel.toLowerCase().includes(q)) return true;
return false;
})
.slice(0, 8)
: [];
// Close on click outside
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
setIsOpen(false);
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
useEffect(() => {
setHighlightedIndex(0);
}, [suggestions.length, searchQuery]);
const handleSelect = (setting: SettingDefinition) => {
setIsOpen(false);
setSearchQuery(setting.name || setting.key);
onSelectSetting?.(setting);
};
const getSettingDisplayName = (setting: SettingDefinition) => {
const formattedKey = formatSettingKey(setting.name || setting.key);
return t(`admin.settings.settingNames.${formattedKey}`, formattedKey);
};
return (
<div className="relative mt-3 sm:hidden">
<div ref={containerRef} className="relative mt-3 sm:hidden">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onChange={(e) => {
setSearchQuery(e.target.value);
setIsOpen(true);
}}
onFocus={() => setIsOpen(true)}
placeholder={t('admin.settings.searchPlaceholder')}
className="w-full rounded-xl border border-dark-700 bg-dark-800 py-2 pl-10 pr-10 text-sm text-dark-100 placeholder-dark-500 focus:border-accent-500 focus:outline-none"
/>
@@ -57,12 +225,37 @@ export function SettingsSearchMobile({
</div>
{searchQuery && (
<button
onClick={() => setSearchQuery('')}
onClick={() => {
setSearchQuery('');
setIsOpen(false);
}}
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
>
<CloseIcon />
</button>
)}
{/* Autocomplete dropdown */}
{isOpen && suggestions.length > 0 && (
<div className="absolute left-0 right-0 top-full z-50 mt-1 max-h-64 overflow-y-auto rounded-xl border border-dark-700 bg-dark-800 py-1 shadow-xl">
{suggestions.map((setting, index) => (
<button
key={setting.key}
onClick={() => handleSelect(setting)}
className={`flex w-full flex-col gap-0.5 px-3 py-2 text-left transition-colors ${
index === highlightedIndex ? 'bg-accent-500/20' : 'hover:bg-dark-700/50'
}`}
>
<span className="truncate text-sm font-medium text-dark-100">
{getSettingDisplayName(setting)}
</span>
<span className="truncate text-xs text-dark-500">
{t(`admin.settings.categories.${setting.category.key}`, setting.category.key)}
</span>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useMemo, useEffect } from 'react';
import { useState, useMemo, useEffect, useCallback } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
@@ -17,6 +17,18 @@ import {
SettingsSearchResults,
} from '../components/admin/SettingsSearch';
// Find section ID by category key
function findSectionByCategory(categoryKey: string): string | null {
for (const section of MENU_SECTIONS) {
for (const item of section.items) {
if (item.categories?.includes(categoryKey)) {
return item.id;
}
}
}
return null;
}
export default function AdminSettings() {
const { t } = useTranslation();
@@ -110,6 +122,19 @@ export default function AdminSettings() {
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
}, [allSettings, favorites]);
// Handle setting selection from autocomplete
const handleSelectSetting = useCallback(
(setting: SettingDefinition) => {
const sectionId = findSectionByCategory(setting.category.key);
if (sectionId) {
setActiveSection(sectionId);
}
// Set search to setting key to filter to just this setting
setSearchQuery(setting.key);
},
[setActiveSection, setSearchQuery],
);
// Render content based on active section
const renderContent = () => {
// If searching, always show search results regardless of active section
@@ -175,7 +200,12 @@ export default function AdminSettings() {
setActiveSection={setActiveSection}
favoritesCount={favorites.length}
/>
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
<SettingsSearchMobile
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
allSettings={allSettings}
onSelectSetting={handleSelectSetting}
/>
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
{renderContent()}
</div>
@@ -247,6 +277,8 @@ export default function AdminSettings() {
searchQuery={searchQuery}
setSearchQuery={setSearchQuery}
resultsCount={filteredSettings.length}
allSettings={allSettings}
onSelectSetting={handleSelectSetting}
/>
</div>
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />