Algolia は運用コストが低く、日本語の検索や各種高度な機能にもかなり対応しているなと思いました。 検索対象数がそれほど多くない場合や、検索はあくまで補足的な機能ということであれば Algolia で十分だなと思いました(若干値段は高いと思いますが)。
一方で、より検索の質を重視したい・幅広い要件に対応したい・検索機能の優劣がサービスの競合優位性になりうるということであれば自由度の高い Elasticsearch を使うのが良さそうと思いました。
objectID
が Algolia 特有のもので、それ以外は必要に応じて変更することができます。{ "objectID": "記事ID", "url": "記事URL", "title": "タイトル", "description": "記事概要", "content": "MarkdownをPlainTextに変換した記事本文" }
objectID
を指定しない場合は自動で生成することもできますが、指定しておく方が運用上楽です。objectID
が同じであれば変更分は追加や更新がされて、それ以外の重複分は無視されるので楽です。getStaticProps
にインデックス情報の整形 + API で情報の送信の機能を実装していきましょう。// src/lib/algolia.ts import removeMd from 'remove-markdown'; export const generateIndex = async (): Promise<void> => { // ローカル or ヘッドレス CMS 経由で記事の情報を取得 // 各自で実装が必要 const posts = async getPosts(...); // posts のプロパティ例 // id, title, description, content const objects = posts.map((post) => { return { objectID: post.id, url: `https://fwywd.com/${post.id}`, title: post.title, description: post.description, content: removeMd(post.content), // markdown => plaintext }; }); // 内容の確認 console.log(objects) };
post.content
に格納しており、alogolia では Platin Text の形式が望ましいため、remove-markdown
をインストールして利用しています。# npm の場合 npm install --save-dev remove-markdown # yarn の場合 yarn add --dev remove-markdown
getStaticProps
に追加して、トップページにアクセスし、ログを確認してみましょう。// src/page/index.tsx ... import { generateIndex } from 'lib/algolia'; ... export default function Index(): JSX.Element { return ( <>...</> ); } export const getStaticProps: GetStaticProps = async () => { await generateIndex(); ... return { props: { ... } }; };
[{ objectID: 'blog/publish', url: 'https://fwywd.com/blog/publish', title: '新事業の fwywd をついに公開しました!', description: 'みなさん、こんにちは。株式会社キカガク代表取締役会長の吉崎です。新しく開設した fwywd(フュード)へお越しいただきまして、誠にありがとうございます。本日 2021 年 5 月 17 日にキカガクの新事業である fwywd を公開することができました。構想から発表まで約5ヶ月の仕込みがありましたので、発表まで辿り着くことができ、大変嬉しく思います。今日まで支えてくださったキカガクのメンバーに改めてお礼を申し上げます。', content: '\n' + '事業領域の拡大への一歩\n' + ... }]
# npm の場合 npm install --save-dev algoliasearch # yarn の場合 yarn add --dev algoliasearch
// src/lib/algolia.ts import removeMd from 'remove-markdown'; import algoliasearch from 'algoliasearch'; // 追加 export const generateIndex = async (): Promise<void> => { const posts = async getPosts(...); const objects = posts.map((post) => { return { objectID: post.id, url: `https://fwywd.com/${post.id}`, title: post.title, description: post.description, content: removeMd(post.content), }; }); // 追加 const client = algoliasearch('Application ID', 'Admin API Key'); const index = client.initIndex('新規で作成した index の名前:今回の例では fwywd'); await index.saveObjects(objects, { autoGenerateObjectIDIfNotExist: true }); };
getStaticProps
に追加したため、Vercel へデプロイする時に自動的に最新のインデックス情報が更新されるようになります。NODE_ENV
が production
のときのみ、インデックス作成および API 経由での情報の送信を行うように条件を分岐しておきましょう。// src/page/index.tsx ... import { generateIndex } from 'lib/algolia'; ... export default function Index(): JSX.Element { return ( <>...</> ); } export const getStaticProps: GetStaticProps = async () => { // 条件分岐を追記 if (process.env.NODE_ENV === 'production') { await generateIndex(); } ... return { props: { ... } }; };
# npm の場合 npm install --save-dev react-instantsearch-dom # yarn の場合 yarn add --dev react-instantsearch-dom
Search
コンポーネントを作成し、ヘッダー位置に挿入しました。// components/common/Search.tsx import algoliasearch from 'algoliasearch/lite'; import { InstantSearch, SearchBox, Hits } from 'react-instantsearch-dom'; export default function Search(): JSX.Element { const searchClient = algoliasearch('Application ID', 'Search-Only API Key'); const indexName = '新規で作成した index の名前:今回の例では fwywd'; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <SearchBox /> <Hits /> </InstantSearch> </div> ); }
{"hit": {"url": ...}
となっており、現状では検索結果として使いにくいため、まずはタイトルだけを抽出して、その URL をリンクとして設定できるようにしましょう。Hits
コンポーネントの引数に hitComponent
を指定すると良いことがわかり、以下のように書くところに辿り着きました。補足:環境変数
ここから API キーなどは.env
ファイルに格納して参照しています。
Admin API Key
のみサーバーサイドで隠蔽したいため、NEXT_PUBLIC_
を外しています。 参考記事:Next.js における環境変数 (env) の基本的な設定方法
# .env.development # 追記 NEXT_PUBLIC_ALGOLIA_APPLICATION_ID='Application ID' NEXT_PUBLIC_ALGOLIA_INDEX='IndexName' NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY='Search API Key' ALGOLIA_ADMIN_API_KEY='Admin API Key'
// src/components/common/Search.tsx import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits } from 'react-instantsearch-dom'; export default function Search(): JSX.Element { const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <SearchBox /> <Hits hitComponent={hitComponent} /> </InstantSearch> </div> ); }
hitComponent
が検索結果に対して操作ができるもので、以下のように定義しています。// src/components/common/HitComponent import Link from 'next/link'; import { Hit } from 'react-instantsearch-core'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <a className="hover:text-[#06bbbc]">{hit.title}</a> </Link> </div> ); } export const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={() => null} /> );
Hits
コンポーネントの引数がちょっと特殊であるため最初はわかりにくいかも知れませんが、一度設定してしまえば、あとは HitComponent
を編集すれば OK です。Configure
コンポーネントを追加するだけでとても簡単です。// src/components/common/Search.tsx import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits, Configure } from 'react-instantsearch-dom'; export default function Search(): JSX.Element { const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> {/* <== 追記 */} <SearchBox /> <Hits hitComponent={hitComponent} /> </InstantSearch> </div> ); }
Pagination
コンポーネントを追加するだけで良いため簡単です。// src/components/common/Search.tsx import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits, Configure, Pagination } from 'react-instantsearch-dom'; export default function Search(): JSX.Element { const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <Hits hitComponent={hitComponent} /> <Pagination /> {/* <== 追記 */} </InstantSearch> </div> ); }
# npm の場合 npm install instantsearch.css # yarn の場合 yarn add instantsearch.css
node_modules/instantsearch.css
下に CSS を含んだパッケージがインストールされます。algolia.css
、別パターンの satellite.css
が用意されています。reset.css
もあります。
それぞれにファイルを最小化 min
版がありますので、こちらを使うと良いでしょう。// src/components/common/Search.tsx import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits, Configure, Pagination } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/algolia-min.css'; // <== 追記:使いたいスタイルに合わせて変更 export default function Search(): JSX.Element { const searchClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <Hits hitComponent={hitComponent} /> <Pagination /> </InstantSearch> </div> ); }
Search
のコンポーネント自体で読み込むのではなく、_app.tsx
でグローバルに読み込んでいました。@algolia/*
で定義されているパッケージが必要となります。# npm の場合 npm install --save-dev @algolia/client-search # yarn の場合 yarn add --dev @algolia/client-search
// src/components/common/Search.tsx import { MultipleQueriesQuery } from '@algolia/client-search'; // <== 追記 import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits, Configure, Pagination } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/satellite-min.css'; export default function Search(): JSX.Element { const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; const algoliaClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); // <== 名称を変更 // こちらに設定を追記していく const searchClient = { ...algoliaClient, search(requests: MultipleQueriesQuery[]) { return algoliaClient.search(requests); }, }; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <Hits hitComponent={hitComponent} /> <Pagination /> </InstantSearch> </div> ); }
!params?.query
の場合には検索結果が何もないとモックした情報 mock
を送ります。// src/components/common/Search.tsx import { MultipleQueriesQuery } from '@algolia/client-search'; import algoliasearch from 'algoliasearch/lite'; import { hitComponent } from 'components/common/HitComponent'; import { InstantSearch, SearchBox, Hits, Configure, Pagination } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/satellite-min.css'; export default function Search(): JSX.Element { const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; const algoliaClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); // 検索結果なしのモック情報 const mock = { hits: [], nbHits: 0, nbPages: 0, page: 0, processingTimeMS: 0, }; // 空文字の場合は何もない情報をモックして渡す const searchClient = { ...algoliaClient, search(requests: MultipleQueriesQuery[]) { if (requests.every(({ params }) => !params?.query)) { return Promise.resolve(mock); } return algoliaClient.search(requests); }, }; return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <Hits hitComponent={hitComponent} /> <Pagination /> </InstantSearch> </div> ); }
Promise.resolve
の中を map
で配列を返すように指定していましたが、無駄なページネーションが表示されるため、この点は変更しています。searchClient
周りも情報が多くなってきたため、管理がしやすいように切り出しておきましょう。// src/lib/searchClient.ts import { MultipleQueriesQuery } from '@algolia/client-search'; import algoliasearch from 'algoliasearch/lite'; export const indexName = process.env.NEXT_PUBLIC_ALGOLIA_INDEX || ''; export const algoliaClient = algoliasearch( process.env.NEXT_PUBLIC_ALGOLIA_APPLICATION_ID || '', process.env.NEXT_PUBLIC_ALGOLIA_SEARCH_API_KEY || '', ); // 空文字の場合は何もない情報をモックして渡す export const searchClient = { ...algoliaClient, search(requests: MultipleQueriesQuery[]) { if (requests.every(({ params }) => !params?.query)) { return Promise.resolve(mock); } return algoliaClient.search(requests); }, }; // 検索結果なしのモック情報 const mock = { hits: [], nbHits: 0, nbPages: 0, page: 0, processingTimeMS: 0, };
// src/component/common/Search.tsx import { hitComponent } from 'components/common/HitComponent'; import { indexName, searchClient } from 'lib/searchClient'; import { InstantSearch, SearchBox, Hits, Configure, Pagination } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/satellite-min.css'; export default function Search(): JSX.Element { return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <Hits hitComponent={hitComponent} /> <Pagination /> </InstantSearch> </div> ); }
Link
コンポーネントで遷移をさせると、ページ遷移後も検索結果が残ってしまうことがあります。connectSearchBox
を利用すると良さそうです。Hits
コンポーネントを SearchResult
コンポーネント内に格納しただけで、特に動作の変更はまだありません。// src/component/common/Search.tsx import { indexName, searchClient } from 'lib/searchClient'; import { InstantSearch, SearchBox, Configure, Pagination } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/satellite-min.css'; import { SearchResult } from './HitComponent'; // <== 変更 export default function Search(): JSX.Element { return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <SearchResult /> {/* Hits => SearchResult */} <Pagination /> </InstantSearch> </div> ); }
// src/components/common/HitComponent.tsx import Link from 'next/link'; import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <a className="hover:text-[#06bbbc]">{hit.title}</a> </Link> </div> ); } // 初期の雛形 export const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={() => null} /> ); return <Hits hitComponent={hitComponent} />; });
// src/components/common/HitComponent.tsx import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; // 追加 import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <button className="hover:text-[#06bbbc]" onClick={onClick}> {hit.title} </button> </Link> </div> ); } export const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { // refine: function => 現状のクエリを変更 // currentRefinement: string => 現状のクエリ const [isShow, setShow] = useState<boolean>(false); // マウント時に実行 useEffect(() => { // !! は currentRefinement が空文字か判定 (true or false) setShow(!!currentRefinement); // => 空文字は表示しないようにセットされる }, [currentRefinement]); // currentRefinement に変化があれば再度実行 // リセットと同時に useCallback でメモ化して無駄なレンダリングを防ぐ const handleResetSearchWords = useCallback(() => { refine(''); // クエリを空文字に指定してリセット }, [refine]); const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={handleResetSearchWords} /> ); if (!isShow) return null; return <Hits hitComponent={hitComponent} />; });
connectSearchBox
の引数 refine
と currentRefinement
は公式ページから役割を確認しました。補足 最終的な成果物にはLink
コンポーネントを使わずにa
タグでの遷移に切り替えており、この処理は動作的には必要なくなりました。
ただし、もう状態管理など少し込みいった実装を行えば、この方法のまま進めることができ、こちらがベストですので知っておいて損はありません。
Pagination
コンポーネントも SearchResult
コンポーネントに包含してしまいましょう。HitComponent.tsx
から SearchResult.tsx
にファイル名を変更しているところも注意です。// src/components/common/Search.tsx import SearchResult from 'components/common/SearchResult'; import { indexName, searchClient } from 'lib/searchClient'; import { InstantSearch, SearchBox, Configure } from 'react-instantsearch-dom'; import 'instantsearch.css/themes/satellite-min.css'; export default function Search(): JSX.Element { return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <SearchResult /> </InstantSearch> </div> ); }
// src/components/common/SearchResult.tsx import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox, Pagination } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <button className="hover:text-[#06bbbc]" onClick={onClick}> {hit.title} </button> </Link> </div> ); } const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { const [isShow, setShow] = useState<boolean>(false); useEffect(() => { setShow(!!currentRefinement); }, [currentRefinement]); const handleResetSearchWords = useCallback(() => { refine(''); }, [refine]); const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={handleResetSearchWords} /> ); if (!isShow) return null; return ( <> <Hits hitComponent={hitComponent} /> <Pagination /> {/* 追加 */} </> ); }); export default SearchResult;
title
と description
のみを検索対象として絞ってみましょう。content
まで入れてしまうと React
や Next.js
といった用語がどのような記事でも必ず入ってしまうため、検索の質が落ちてしまうためです。content
を含めるかどうかは規模によっても変わってくるため、その状況に合わせて検討しましょう。Highlight
コンポーネントで簡単に実装できます。// 変更箇所 function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <button className="hover:text-[#06bbbc]" onClick={onClick}> <Highlight attribute="title" hit={hit} /> {/* <== title の属性を表示 */} </button> </Link> </div> ); }
// src/components/common/SearchResult.tsx import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox, Pagination, Highlight } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <button className="hover:text-[#06bbbc]" onClick={onClick}> <Highlight attribute="title" hit={hit} /> </button> </Link> </div> ); } const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { const [isShow, setShow] = useState<boolean>(false); useEffect(() => { setShow(!!currentRefinement); }, [currentRefinement]); const handleResetSearchWords = useCallback(() => { refine(''); }, [refine]); const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={handleResetSearchWords} /> ); if (!isShow) return null; return ( <> <Hits hitComponent={hitComponent} /> <Pagination /> {/* 追加 */} </> ); }); export default SearchResult;
Algolia requires that you use this widget if you only use your free usage credits, or are on a Community or Free legacy plan.
PowerdBy
コンポーネントが用意されているため、そちらを追加するだけです。Search
コンポーネント内に追加すると検索されていない時も表示されてしまうため、SearchResult
コンポーネントないに追記しておくと良いでしょう。// 変更点 return ( <> <Hits hitComponent={hitComponent} /> <Pagination /> <PoweredBy /> {/* 追加 */} </> );
// src/components/common/SearchResult.tsx import Link from 'next/link'; import { useEffect, useState, useCallback } from 'react'; import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox, Pagination, Highlight, PoweredBy } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <div> <Link href={hit.url}> <button className="hover:text-[#06bbbc]" onClick={onClick}> <Highlight attribute="title" hit={hit} /> </button> </Link> </div> ); } const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { const [isShow, setShow] = useState<boolean>(false); useEffect(() => { setShow(!!currentRefinement); }, [currentRefinement]); const handleResetSearchWords = useCallback(() => { refine(''); }, [refine]); const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={handleResetSearchWords} /> ); if (!isShow) return null; return ( <> <Hits hitComponent={hitComponent} /> <Pagination /> <PoweredBy /> {/* 追加 */} </> ); }); export default SearchResult;
PageHeader
に追加していた Search
コンポーネントはモーダルウィンドウ内に移植するため、ひとまず除去して、その位置にこれから紹介する SearchForm
を挿入しています。# npm の場合 npm install --save-dev @headlessui/react # yarn の場合 yarn add --dev @headlessui/react
Modal
付きの検索ボタンを設定しました。// src/components/common/SearchForm.tsx import { Dialog, Transition } from '@headlessui/react'; import SearchIcon from 'components/icons/Search'; import { useState, Fragment } from 'react'; export default function SearchForm(): JSX.Element { const [isOpen, setIsOpen] = useState<boolean>(false); const openModal = () => setIsOpen(true); const closeModal = () => setIsOpen(false); return ( <> <button onClick={openModal} className="flex items-center px-4 py-3 text-sm text-gray-500 rounded-full shadow focus:outline-none w-72 hover:bg-[#f1f7f8] hover:text-primary-dark hover:shadow-lg hover:duration-500" > <SearchIcon /> <span className="ml-2">記事を検索</span> </button> <Transition appear show={isOpen} as={Fragment}> <Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" onClose={closeModal}> <div className="min-h-screen px-4 text-center"> <Dialog.Overlay className="fixed inset-0 opacity-50 bg-primary-lightest" /> <Transition.Child as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100" leave="ease-in duration-200" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > <div className="inline-block w-full max-w-md p-6 my-8 overflow-hidden text-left align-middle bg-white shadow-xl transition-all transform rounded-2xl"> <Dialog.Title as="h3" className="text-lg font-medium text-gray-900 leading-6"> 検索結果 </Dialog.Title> <div className="mt-2"> <p className="text-sm text-gray-500">ここに検索結果を挿入していきます 。</p> </div> <div className="mt-4"> <button type="button" className="inline-flex justify-center px-4 py-2 text-sm font-medium text-blue-900 bg-blue-100 border border-transparent rounded-md hover:bg-blue-200 focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 focus-visible:ring-blue-500" onClick={closeModal} > 閉じる </button> </div> </div> </Transition.Child> </div> </Dialog> </Transition> </> ); }
補足1
内部リンクからの検索結果が残る問題を解決してきましたが、その後、モーダルウィンドウがページ遷移後も消えず、泣く泣くLink
コンポーネントを使わずにa
タグへと変えました。
コンポーネント間で状態の共有ができればLink
での実装も可能ですが、useContext
が煩わしいため、今回は動作優先としました。
状態管理用の外部パッケージとして Atom などがあり、こちらを使うと非常に簡単ですので、近いうちにこちらでリファクタリングする予定です。
補足2
instantsearch.css
はSearch
コンポーネント内で読み込んでいましたが、CSS の上書きをするのであれば_app.tsx
で読み込まないといけませんでした。
まず前提として、CSS Modules とは異なり、ビルドを行わない対象の CSS は_app.tsx
でグローバルに読み込む必要があります。
algolia で指定されているクラス名に合わせるには CSS Modules が使えないため、必然的に上書き用のalgolia.css
は_app.tsx
でグローバルに読み込む必要があります。
Search
コンポーネント内でinstantsearch.css
を読み込むと、_app.tsx
の後に読み込まれるため、自作したalgolia.css
で上書きすることができませんでした。
したがって、_app.tsx
内でinstantsearch.css
を読み込んだ後に自作したalgolia.css
を読み込むといった方法に限られるようです。
SearchForm
をヘッダーに挿入しています。// src/components/common/Search.tsx import SearchResult from 'components/common/SearchResult'; import { indexName, searchClient } from 'lib/searchClient'; import { InstantSearch, SearchBox, Configure } from 'react-instantsearch-dom'; export default function Search(): JSX.Element { return ( <div> <InstantSearch indexName={indexName} searchClient={searchClient}> <Configure hitsPerPage={5} /> <SearchBox /> <SearchResult /> </InstantSearch> </div> ); }
// src/components/common/SearchForm.tsx import { Dialog, Transition } from '@headlessui/react'; import Search from 'components/common/Search'; import SearchIcon from 'components/icons/SearchIcon'; import { useState, Fragment } from 'react'; export default function SearchForm(): JSX.Element { const [isOpen, setIsOpen] = useState<boolean>(false); const openModal = () => setIsOpen(true); const closeModal = () => setIsOpen(false); return ( <> <button onClick={openModal} className="flex items-center px-4 py-3 text-sm text-gray-500 rounded-full shadow focus:outline-none w-72 hover:bg-[#f1f7f8] hover:text-primary-dark hover:shadow-lg hover:duration-500" > <SearchIcon /> <span className="ml-2">記事を検索</span> </button> <Transition appear show={isOpen} as={Fragment}> <Dialog as="div" className="fixed inset-0 z-10 overflow-y-auto" onClose={closeModal}> <div className="min-h-screen px-4 text-center"> <Dialog.Overlay className="fixed inset-0 opacity-50 bg-primary-lightest" /> <Transition.Child as={Fragment} enter="ease-out duration-300" enterFrom="opacity-0 scale-95" enterTo="opacity-100 scale-100" leave="ease-in duration-200" leaveFrom="opacity-100 scale-100" leaveTo="opacity-0 scale-95" > <div className="inline-block w-full max-w-2xl p-6 my-8 overflow-hidden text-left align-middle bg-white shadow-xl transition-all transform rounded-2xl"> <Search /> </div> </Transition.Child> </div> </Dialog> </Transition> </> ); }
// src/components/common/SearchResult.tsx import { useEffect, useState, useCallback } from 'react'; import { Hit } from 'react-instantsearch-core'; import { Hits, connectSearchBox, Pagination, Highlight, PoweredBy } from 'react-instantsearch-dom'; interface HitDoc { objectID: string; url: string; title: string; description: string; content: string; } interface Props { hit: Hit<HitDoc>; } interface HitComponentProps extends Props { onClick: () => void; } function HitComponent({ hit, onClick }: HitComponentProps): JSX.Element { return ( <a href={hit.url}> <button onClick={onClick} className="flex justify-start w-full px-5 py-5"> <Highlight attribute="title" hit={hit} /> </button> </a> ); } const SearchResult = connectSearchBox(({ refine, currentRefinement }) => { const [isShow, setShow] = useState<boolean>(false); useEffect(() => { setShow(!!currentRefinement); }, [currentRefinement]); const handleResetSearchWords = useCallback(() => { refine(''); }, [refine]); const hitComponent = ({ hit }: Props): JSX.Element => ( <HitComponent hit={hit} onClick={handleResetSearchWords} /> ); if (!isShow) return null; return ( <> <p className="mt-5 text-sm font-semibold tracking-wider text-gray-500">記事一覧</p> <Hits hitComponent={hitComponent} /> <Pagination /> <PoweredBy /> </> ); }); export default SearchResult;
// src/components/icons/SearchIcon.tsx interface Props { w?: number; h?: number; } export default function Search({ w = 6, h = 6 }: Props): JSX.Element { return ( <svg xmlns="http://www.w3.org/2000/svg" className={`w-${w} h-${h} `} fill="none" viewBox="0 0 24 24" stroke="currentColor" > <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" /> </svg> ); }
/* src/styles/algolia.css */ .ais-Hits-list { padding: 5px 0; } .ais-Hits-item { background-color: #fafafa; border-radius: 5px; box-shadow: none; display: block; margin: 10px 0; padding: 0; } .ais-Hits-item:hover { background-color: #d9ebeb; } .ais-Hits-item:first-of-type, .ais-InfiniteHits-item:first-of-type { border-radius: 5px; } .ais-Highlight { font-weight: 600; } .ais-Highlight-highlighted { background-color: transparent; color: #06bbbc; } .ais-Pagination { display: flex; justify-content: center; } .ais-PoweredBy { display: flex; justify-content: flex-end; } .ais-PoweredBy-text { font-size: small; margin-right: 5px; }
src/pages/_app.tsx ... import 'instantsearch.css/themes/satellite-min.css'; import 'styles/algolia.css'; export default function App({ Component, pageProps }: AppProps): JSX.Element { ... return <Component {...pageProps} />; }
新着記事
関連記事
著者