Leaving the field scheduled a close 120 ms later; coming back and typing within that window let the old timer hide the fresh suggestions. Focus now cancels the pending close. This also made the search box test flaky. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
233 lines
8.3 KiB
TypeScript
233 lines
8.3 KiB
TypeScript
import { unwrap } from '@pipu/core';
|
|
import { keepPreviousData, useQuery } from '@tanstack/react-query';
|
|
import { useNavigate } from '@tanstack/react-router';
|
|
import { useEffect, useId, useRef, useState, type KeyboardEvent } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
|
|
import { useSession } from '../app/session';
|
|
import { Cover } from '../entities/catalog';
|
|
import { formatDuration } from '../shared/lib/format';
|
|
import { searchIcon } from '../shared/ui/icons';
|
|
import { cx, Icon } from '../shared/ui/ui';
|
|
import s from './SearchBox.module.css';
|
|
|
|
const DEBOUNCE_MS = 150;
|
|
|
|
type Item = {
|
|
key: string;
|
|
group: 'artists' | 'albums' | 'tracks';
|
|
title: string;
|
|
subtitle: string;
|
|
cover: { id: string; cover_url?: string | null; cover_colors?: string[] };
|
|
round?: boolean;
|
|
go: () => void;
|
|
};
|
|
|
|
/**
|
|
* Поиск в топбаре: подсказки по мере ввода, Enter — страница поиска или выбранный элемент.
|
|
* Без фокуса и текста свёрнут в кнопку с лупой; поле раскрывается влево с анимацией.
|
|
*/
|
|
export function SearchBox({ align = 'center' }: { align?: 'left' | 'center' | 'right' }) {
|
|
const { t } = useTranslation();
|
|
const session = useSession();
|
|
const navigate = useNavigate();
|
|
const listId = useId();
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
const [q, setQ] = useState('');
|
|
const [debounced, setDebounced] = useState('');
|
|
const [open, setOpen] = useState(false);
|
|
const [active, setActive] = useState(-1);
|
|
const [focused, setFocused] = useState(false);
|
|
const blurTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
// Свёрнут в круглую кнопку, пока не нужен: раскрывается по клику, «/» или Ctrl+K.
|
|
const expanded = focused || q !== '';
|
|
|
|
useEffect(() => {
|
|
const id = setTimeout(() => setDebounced(q.trim()), DEBOUNCE_MS);
|
|
return () => clearTimeout(id);
|
|
}, [q]);
|
|
|
|
// «/» или Ctrl+K — фокус в поиск (если пользователь не печатает в другом поле).
|
|
useEffect(() => {
|
|
const onKey = (e: globalThis.KeyboardEvent) => {
|
|
const target = e.target as HTMLElement;
|
|
const typing = target.closest('input, textarea, select, [contenteditable="true"]');
|
|
if ((e.key === '/' && !typing) || (e.key.toLowerCase() === 'k' && (e.ctrlKey || e.metaKey))) {
|
|
e.preventDefault();
|
|
inputRef.current?.focus();
|
|
}
|
|
};
|
|
document.addEventListener('keydown', onKey);
|
|
return () => document.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
|
|
const suggest = useQuery({
|
|
queryKey: ['suggest', debounced],
|
|
enabled: debounced !== '',
|
|
placeholderData: keepPreviousData,
|
|
// signal отменяет устаревший запрос, когда пользователь печатает дальше.
|
|
queryFn: async ({ signal }) =>
|
|
unwrap(await session.client.GET('/api/v1/search/suggest', { params: { query: { q: debounced } }, signal })),
|
|
staleTime: 30_000,
|
|
});
|
|
const data = debounced ? suggest.data : undefined;
|
|
|
|
const close = () => {
|
|
setOpen(false);
|
|
setActive(-1);
|
|
};
|
|
const goSearch = () => {
|
|
const query = q.trim();
|
|
if (!query) return;
|
|
close();
|
|
inputRef.current?.blur();
|
|
void navigate({ to: '/search', search: { q: query } });
|
|
};
|
|
|
|
const items: Item[] = [];
|
|
if (data) {
|
|
for (const a of data.artists)
|
|
items.push({
|
|
key: `ar-${a.id}`,
|
|
group: 'artists',
|
|
title: a.name,
|
|
subtitle: t('search.artist_kind'),
|
|
cover: { id: a.id },
|
|
round: true,
|
|
go: () => void navigate({ to: '/artist/$id', params: { id: a.id } }),
|
|
});
|
|
for (const al of data.albums)
|
|
items.push({
|
|
key: `al-${al.id}`,
|
|
group: 'albums',
|
|
title: al.title,
|
|
subtitle: [al.artist.name, al.year].filter(Boolean).join(' · '),
|
|
cover: al,
|
|
go: () => void navigate({ to: '/album/$id', params: { id: al.id } }),
|
|
});
|
|
for (const tr of data.tracks)
|
|
items.push({
|
|
key: `tr-${tr.id}`,
|
|
group: 'tracks',
|
|
title: tr.title,
|
|
subtitle: `${tr.artists.map((a) => a.name).join(', ')} · ${formatDuration(tr.duration_ms)}`,
|
|
cover: tr.album,
|
|
// Воспроизведение — M3; пока трек открывает свой альбом.
|
|
go: () => void navigate({ to: '/album/$id', params: { id: tr.album.id } }),
|
|
});
|
|
}
|
|
|
|
const choose = (i: number) => {
|
|
const it = items[i];
|
|
if (!it) return goSearch();
|
|
close();
|
|
setQ('');
|
|
inputRef.current?.blur();
|
|
it.go();
|
|
};
|
|
|
|
const onKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'ArrowDown') {
|
|
e.preventDefault();
|
|
setOpen(true);
|
|
setActive((i) => Math.min(items.length - 1, i + 1));
|
|
} else if (e.key === 'ArrowUp') {
|
|
e.preventDefault();
|
|
setActive((i) => Math.max(-1, i - 1));
|
|
} else if (e.key === 'Enter') {
|
|
e.preventDefault();
|
|
choose(active);
|
|
} else if (e.key === 'Escape') {
|
|
close();
|
|
inputRef.current?.blur();
|
|
}
|
|
};
|
|
|
|
const showList = open && debounced !== '' && data !== undefined;
|
|
const groups: Item['group'][] = ['artists', 'albums', 'tracks'];
|
|
const optionId = (i: number) => `${listId}-${i}`;
|
|
|
|
return (
|
|
<div className={cx(s.box, expanded && s.expanded, s[align])}>
|
|
<Icon src={searchIcon} size={18} className={s.icon} />
|
|
<input
|
|
ref={inputRef}
|
|
type="search"
|
|
className={s.input}
|
|
value={q}
|
|
placeholder={t('search.placeholder')}
|
|
aria-label={t('search.label')}
|
|
role="combobox"
|
|
aria-expanded={showList}
|
|
aria-controls={listId}
|
|
aria-autocomplete="list"
|
|
aria-activedescendant={showList && active >= 0 ? optionId(active) : undefined}
|
|
onChange={(e) => {
|
|
setQ(e.target.value);
|
|
setOpen(true);
|
|
setActive(-1);
|
|
}}
|
|
onFocus={() => {
|
|
// Вернулись в поле раньше, чем сработало закрытие по blur, — подсказки не прячем.
|
|
clearTimeout(blurTimer.current);
|
|
setFocused(true);
|
|
setOpen(true);
|
|
}}
|
|
onBlur={() => {
|
|
setFocused(false);
|
|
blurTimer.current = setTimeout(close, 120);
|
|
}}
|
|
onKeyDown={onKeyDown}
|
|
/>
|
|
<kbd className={s.kbd} aria-hidden>
|
|
/
|
|
</kbd>
|
|
{showList && (
|
|
<div className={s.popover}>
|
|
{data.corrected_query && (
|
|
<div className={s.corrected}>{t('search.corrected', { query: data.corrected_query })}</div>
|
|
)}
|
|
<div id={listId} role="listbox" aria-label={t('search.label')}>
|
|
{items.length === 0 && <div className={s.nothing}>{t('search.nothing')}</div>}
|
|
{groups.map((g) => {
|
|
const groupItems = items.map((it, i) => ({ it, i })).filter(({ it }) => it.group === g);
|
|
if (groupItems.length === 0) return null;
|
|
return (
|
|
<div key={g} role="group" aria-label={t(`search.${g}`)}>
|
|
<div className={s.groupTitle}>{t(`search.${g}`)}</div>
|
|
{groupItems.map(({ it, i }) => (
|
|
<div
|
|
key={it.key}
|
|
id={optionId(i)}
|
|
role="option"
|
|
aria-selected={i === active}
|
|
className={cx(s.option, i === active && s.active)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onMouseEnter={() => setActive(i)}
|
|
onClick={() => choose(i)}
|
|
>
|
|
<Cover album={it.cover} size={36} round={it.round} />
|
|
<span className={s.optionText}>
|
|
<span className={s.optionTitle}>{it.title}</span>
|
|
<span className={s.optionSub}>{it.subtitle}</span>
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
<button
|
|
type="button"
|
|
className={s.all}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={goSearch}
|
|
>
|
|
{t('search.show_all')}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|