mirror of
https://github.com/chillpadclub/bedolaga-cabinet.git
synced 2026-07-28 17:43:47 +00:00
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:
@@ -1,54 +1,222 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { SettingDefinition } from '../../api/adminSettings';
|
||||||
import { SearchIcon, CloseIcon } from './icons';
|
import { SearchIcon, CloseIcon } from './icons';
|
||||||
|
import { formatSettingKey } from './utils';
|
||||||
|
|
||||||
interface SettingsSearchProps {
|
interface SettingsSearchProps {
|
||||||
searchQuery: string;
|
searchQuery: string;
|
||||||
setSearchQuery: (query: string) => void;
|
setSearchQuery: (query: string) => void;
|
||||||
resultsCount?: number;
|
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 { 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 (
|
return (
|
||||||
<>
|
<div ref={containerRef} className="relative hidden sm:block">
|
||||||
{/* Search - desktop */}
|
<input
|
||||||
<div className="relative hidden sm:block">
|
ref={inputRef}
|
||||||
<input
|
type="text"
|
||||||
type="text"
|
value={searchQuery}
|
||||||
value={searchQuery}
|
onChange={(e) => {
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
setSearchQuery(e.target.value);
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
setIsOpen(true);
|
||||||
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"
|
}}
|
||||||
/>
|
onFocus={() => setIsOpen(true)}
|
||||||
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
onKeyDown={handleKeyDown}
|
||||||
<SearchIcon />
|
placeholder={t('admin.settings.searchPlaceholder')}
|
||||||
</div>
|
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"
|
||||||
{searchQuery && (
|
/>
|
||||||
<button
|
<div className="absolute left-3 top-1/2 -translate-y-1/2 text-dark-500">
|
||||||
onClick={() => setSearchQuery('')}
|
<SearchIcon />
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
|
||||||
>
|
|
||||||
<CloseIcon />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
{searchQuery && (
|
||||||
|
<button
|
||||||
|
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({
|
export function SettingsSearchMobile({
|
||||||
searchQuery,
|
searchQuery,
|
||||||
setSearchQuery,
|
setSearchQuery,
|
||||||
|
allSettings,
|
||||||
|
onSelectSetting,
|
||||||
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
}: Omit<SettingsSearchProps, 'resultsCount'>) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="relative mt-3 sm:hidden">
|
<div ref={containerRef} className="relative mt-3 sm:hidden">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => {
|
||||||
|
setSearchQuery(e.target.value);
|
||||||
|
setIsOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => setIsOpen(true)}
|
||||||
placeholder={t('admin.settings.searchPlaceholder')}
|
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"
|
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>
|
</div>
|
||||||
{searchQuery && (
|
{searchQuery && (
|
||||||
<button
|
<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"
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-dark-500 transition-colors hover:text-dark-300"
|
||||||
>
|
>
|
||||||
<CloseIcon />
|
<CloseIcon />
|
||||||
</button>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useEffect } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
import { adminSettingsApi, SettingDefinition } from '../api/adminSettings';
|
||||||
@@ -17,6 +17,18 @@ import {
|
|||||||
SettingsSearchResults,
|
SettingsSearchResults,
|
||||||
} from '../components/admin/SettingsSearch';
|
} 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() {
|
export default function AdminSettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -110,6 +122,19 @@ export default function AdminSettings() {
|
|||||||
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
return allSettings.filter((s: SettingDefinition) => favorites.includes(s.key));
|
||||||
}, [allSettings, favorites]);
|
}, [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
|
// Render content based on active section
|
||||||
const renderContent = () => {
|
const renderContent = () => {
|
||||||
// If searching, always show search results regardless of active section
|
// If searching, always show search results regardless of active section
|
||||||
@@ -175,7 +200,12 @@ export default function AdminSettings() {
|
|||||||
setActiveSection={setActiveSection}
|
setActiveSection={setActiveSection}
|
||||||
favoritesCount={favorites.length}
|
favoritesCount={favorites.length}
|
||||||
/>
|
/>
|
||||||
<SettingsSearchMobile searchQuery={searchQuery} setSearchQuery={setSearchQuery} />
|
<SettingsSearchMobile
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
allSettings={allSettings}
|
||||||
|
onSelectSetting={handleSelectSetting}
|
||||||
|
/>
|
||||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||||
{renderContent()}
|
{renderContent()}
|
||||||
</div>
|
</div>
|
||||||
@@ -247,6 +277,8 @@ export default function AdminSettings() {
|
|||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
resultsCount={filteredSettings.length}
|
resultsCount={filteredSettings.length}
|
||||||
|
allSettings={allSettings}
|
||||||
|
onSelectSetting={handleSelectSetting}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
<SettingsSearchResults searchQuery={searchQuery} resultsCount={filteredSettings.length} />
|
||||||
|
|||||||
Reference in New Issue
Block a user