refactor: search modal component

This commit is contained in:
Sonny
2023-12-23 16:38:05 +01:00
committed by Sonny
parent cfba05c58d
commit 480ca9bfa6
6 changed files with 150 additions and 126 deletions

View File

@@ -2,7 +2,6 @@ import ButtonLink from 'components/ButtonLink';
import Modal from 'components/Modal/Modal'; import Modal from 'components/Modal/Modal';
import TextBox from 'components/TextBox'; import TextBox from 'components/TextBox';
import * as Keys from 'constants/keys'; import * as Keys from 'constants/keys';
import PATHS from 'constants/paths';
import { GOOGLE_SEARCH_URL } from 'constants/search-urls'; import { GOOGLE_SEARCH_URL } from 'constants/search-urls';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import useActiveCategory from 'hooks/useActiveCategory'; import useActiveCategory from 'hooks/useActiveCategory';
@@ -11,24 +10,13 @@ import useCategories from 'hooks/useCategories';
import useGlobalHotkeys from 'hooks/useGlobalHotkeys'; import useGlobalHotkeys from 'hooks/useGlobalHotkeys';
import { useLocalStorage } from 'hooks/useLocalStorage'; import { useLocalStorage } from 'hooks/useLocalStorage';
import useModal from 'hooks/useModal'; import useModal from 'hooks/useModal';
import useQueryParam from 'hooks/useQueryParam';
import useSearchItem from 'hooks/useSearchItem';
import { useTranslation } from 'next-i18next'; import { useTranslation } from 'next-i18next';
import { useRouter } from 'next/router'; import { FormEvent, ReactNode, useCallback, useEffect, useState } from 'react';
import {
FormEvent,
ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useHotkeys } from 'react-hotkeys-hook'; import { useHotkeys } from 'react-hotkeys-hook';
import { BsSearch } from 'react-icons/bs'; import { BsSearch } from 'react-icons/bs';
import { import { SearchResult } from 'types/types';
CategoryWithLinks,
LinkWithCategory,
SearchItem,
SearchResult,
} from 'types/types';
import LabelSearchWithGoogle from './LabelSearchWithGoogle'; import LabelSearchWithGoogle from './LabelSearchWithGoogle';
import { SearchFilter } from './SearchFilter'; import { SearchFilter } from './SearchFilter';
import SearchList from './SearchList'; import SearchList from './SearchList';
@@ -40,62 +28,12 @@ interface SearchModalProps {
childClassname?: string; childClassname?: string;
} }
function buildSearchItem(
item: CategoryWithLinks | LinkWithCategory,
type: SearchItem['type'],
): SearchItem {
return {
id: item.id,
name: item.name,
url:
type === 'link'
? (item as LinkWithCategory).url
: `${PATHS.HOME}?categoryId=${item.id}`,
type,
category: type === 'link' ? (item as LinkWithCategory).category : undefined,
};
}
function formatSearchItem(
item: SearchItem,
searchTerm: string,
): SearchResult | null {
const lowerCaseSearchTerm = searchTerm.toLowerCase().trim();
const lowerCaseName = item.name.toLowerCase().trim();
let currentIndex = 0;
let formattedName = '';
for (let i = 0; i < lowerCaseName.length; i++) {
if (lowerCaseName[i] === lowerCaseSearchTerm[currentIndex]) {
formattedName += `<b>${item.name[i]}</b>`;
currentIndex++;
} else {
formattedName += item.name[i];
}
}
if (currentIndex !== lowerCaseSearchTerm.length) {
// Search term not fully matched
return null;
}
return {
id: item.id,
name: <div dangerouslySetInnerHTML={{ __html: formattedName }} />,
url: item.url,
type: item.type,
category: item.category,
};
}
function SearchModal({ function SearchModal({
noHeader = true, noHeader = true,
children, children,
childClassname = '', childClassname = '',
}: Readonly<SearchModalProps>) { }: Readonly<SearchModalProps>) {
const { t } = useTranslation(); const { t } = useTranslation();
const router = useRouter();
const autoFocusRef = useAutoFocus(); const autoFocusRef = useAutoFocus();
const { categories } = useCategories(); const { categories } = useCategories();
const { setActiveCategory } = useActiveCategory(); const { setActiveCategory } = useActiveCategory();
@@ -108,12 +46,11 @@ function SearchModal({
'search-category', 'search-category',
false, false,
); );
const [search, setSearch] = useState<string>( const { queryParam, clearQuery } = useQueryParam('q');
(router.query.q as string) || '', const [searchTerm, setSearchTerm] = useState<string>(queryParam);
);
const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null); const [selectedItem, setSelectedItem] = useState<SearchResult | null>(null);
const searchModal = useModal(!!search && typeof window !== 'undefined'); const searchModal = useModal(!!searchTerm);
useEffect( useEffect(
() => setGlobalHotkeysEnabled(!searchModal.isShowing), () => setGlobalHotkeysEnabled(!searchModal.isShowing),
@@ -122,63 +59,28 @@ function SearchModal({
const handleCloseModal = useCallback(() => { const handleCloseModal = useCallback(() => {
searchModal.close(); searchModal.close();
setSearch(''); clearQuery();
if (!!search) { setSearchTerm('');
router.replace({ }, [searchModal, clearQuery]);
query: undefined,
});
}
}, [router, search, searchModal]);
const itemsSearch = useMemo<SearchItem[]>(() => { const searchItemsResult = useSearchItem({
return categories.reduce((acc, category) => { searchTerm,
const categoryItem = buildSearchItem(category, 'category'); disableLinks: !canSearchLink,
const items: SearchItem[] = category.links.map((link) => disableCategories: !canSearchCategory,
buildSearchItem(link, 'link'), });
);
return [...acc, ...items, categoryItem];
}, [] as SearchItem[]);
}, [categories]);
const itemsCompletion = useMemo(() => { const handleSearchInputChange = (value: string) => setSearchTerm(value);
return itemsSearch.reduce((acc, item) => { const handleCanSearchLink = (checked: boolean) => setCanSearchLink(checked);
const formattedItem = formatSearchItem(item, search); const handleCanSearchCategory = (checked: boolean) =>
setCanSearchCategory(checked);
if (
(canSearchLink && item.type === 'link') ||
(canSearchCategory && item.type === 'category')
) {
return formattedItem ? [...acc, formattedItem] : acc;
}
return acc;
}, [] as SearchResult[]);
}, [itemsSearch, search, canSearchLink, canSearchCategory]);
const canSubmit = useMemo<boolean>(() => search.length > 0, [search]);
const handleSearchInputChange = useCallback(
(value: string) => setSearch(value),
[],
);
const handleCanSearchLink = useCallback(
(checked: boolean) => setCanSearchLink(checked),
[setCanSearchLink],
);
const handleCanSearchCategory = useCallback(
(checked: boolean) => setCanSearchCategory(checked),
[setCanSearchCategory],
);
const handleSubmit = useCallback( const handleSubmit = useCallback(
(event: FormEvent<HTMLFormElement>) => { (event: FormEvent<HTMLFormElement>) => {
event.preventDefault(); event.preventDefault();
handleCloseModal(); handleCloseModal();
if (itemsCompletion.length === 0) { if (searchItemsResult.length === 0) {
return window.open(GOOGLE_SEARCH_URL + encodeURI(search.trim())); return window.open(GOOGLE_SEARCH_URL + encodeURI(searchTerm.trim()));
} }
if (!selectedItem) return; if (!selectedItem) return;
@@ -193,8 +95,8 @@ function SearchModal({
[ [
categories, categories,
handleCloseModal, handleCloseModal,
itemsCompletion.length, searchItemsResult.length,
search, searchTerm,
selectedItem, selectedItem,
setActiveCategory, setActiveCategory,
], ],
@@ -240,7 +142,7 @@ function SearchModal({
<TextBox <TextBox
name='search' name='search'
onChangeCallback={handleSearchInputChange} onChangeCallback={handleSearchInputChange}
value={search} value={searchTerm}
placeholder={t('common:search')} placeholder={t('common:search')}
innerRef={autoFocusRef} innerRef={autoFocusRef}
fieldClass={styles['search-input-field']} fieldClass={styles['search-input-field']}
@@ -253,9 +155,9 @@ function SearchModal({
canSearchCategory={canSearchCategory} canSearchCategory={canSearchCategory}
setCanSearchCategory={handleCanSearchCategory} setCanSearchCategory={handleCanSearchCategory}
/> />
{search.length > 0 && ( {searchTerm.length > 0 && (
<SearchList <SearchList
items={itemsCompletion} items={searchItemsResult}
selectedItem={selectedItem} selectedItem={selectedItem}
setSelectedItem={setSelectedItem} setSelectedItem={setSelectedItem}
noItem={<LabelSearchWithGoogle />} noItem={<LabelSearchWithGoogle />}
@@ -264,7 +166,7 @@ function SearchModal({
)} )}
<button <button
type='submit' type='submit'
disabled={!canSubmit} disabled={searchTerm.length === 0}
style={{ display: 'none' }} style={{ display: 'none' }}
> >
{t('common:confirm')} {t('common:confirm')}

View File

@@ -0,0 +1,19 @@
import { useRouter } from 'next/router';
export default function useQueryParam(param: string) {
const router = useRouter();
const queryParam = (router.query[param] as string) || '';
const clearQuery = () => {
if (!!queryParam) {
router.replace({
query: undefined,
});
}
};
return {
queryParam,
clearQuery,
};
}

View File

@@ -0,0 +1,43 @@
import { buildSearchItem, formatSearchItem } from 'lib/search';
import { useMemo } from 'react';
import { SearchItem, SearchResult } from 'types/types';
import useCategories from './useCategories';
export default function useSearchItem({
searchTerm = '',
disableLinks = false,
disableCategories = false,
}: {
searchTerm: string;
disableLinks?: boolean;
disableCategories?: boolean;
}) {
const { categories } = useCategories();
const itemsSearch = useMemo<SearchItem[]>(() => {
return categories.reduce((acc, category) => {
const categoryItem = buildSearchItem(category, 'category');
const items: SearchItem[] = category.links.map((link) =>
buildSearchItem(link, 'link'),
);
return [...acc, ...items, categoryItem];
}, [] as SearchItem[]);
}, [categories]);
const itemsCompletion = useMemo(() => {
return itemsSearch.reduce((acc, item) => {
const formattedItem = formatSearchItem(item, searchTerm);
if (
(!disableLinks && item.type === 'link') ||
(!disableCategories && item.type === 'category')
) {
return formattedItem ? [...acc, formattedItem] : acc;
}
return acc;
}, [] as SearchResult[]);
}, [itemsSearch, searchTerm, disableLinks, disableCategories]);
return itemsCompletion;
}

56
src/lib/search.tsx Normal file
View File

@@ -0,0 +1,56 @@
import PATHS from 'constants/paths';
import {
CategoryWithLinks,
LinkWithCategory,
SearchItem,
SearchResult,
} from 'types/types';
export function buildSearchItem(
item: CategoryWithLinks | LinkWithCategory,
type: SearchItem['type'],
): SearchItem {
return {
id: item.id,
name: item.name,
url:
type === 'link'
? (item as LinkWithCategory).url
: `${PATHS.HOME}?categoryId=${item.id}`,
type,
category: type === 'link' ? (item as LinkWithCategory).category : undefined,
};
}
export function formatSearchItem(
item: SearchItem,
searchTerm: string,
): SearchResult | null {
const lowerCaseSearchTerm = searchTerm.toLowerCase().trim();
const lowerCaseName = item.name.toLowerCase().trim();
let currentIndex = 0;
let formattedName = '';
for (let i = 0; i < lowerCaseName.length; i++) {
if (lowerCaseName[i] === lowerCaseSearchTerm[currentIndex]) {
formattedName += `<b>${item.name[i]}</b>`;
currentIndex++;
} else {
formattedName += item.name[i];
}
}
if (currentIndex !== lowerCaseSearchTerm.length) {
// Search term not fully matched
return null;
}
return {
id: item.id,
name: <div dangerouslySetInnerHTML={{ __html: formattedName }} />,
url: item.url,
type: item.type,
category: item.category,
};
}

View File

@@ -127,7 +127,7 @@
.react-toggle--checked .react-toggle-thumb { .react-toggle--checked .react-toggle-thumb {
left: 27px; left: 27px;
border-color: #19ab27; border-color: $light-blue;
} }
.react-toggle--focus .react-toggle-thumb { .react-toggle--focus .react-toggle-thumb {

View File

@@ -82,6 +82,10 @@ h6 {
} }
} }
label {
user-select: none;
}
button:not(.reset), button:not(.reset),
a.btn:not(.reset) { a.btn:not(.reset) {
cursor: pointer; cursor: pointer;