Просмотр исходного кода

Merge pull request #8238 from weseek/feat/new-incremental-search

feat: New incremental search
Yuki Takei 2 лет назад
Родитель
Сommit
e0a922f639

+ 1 - 0
apps/app/package.json

@@ -234,6 +234,7 @@
     "bootstrap": "^5.3.1",
     "connect-browser-sync": "^2.1.0",
     "diff2html": "^3.4.35",
+    "downshift": "^8.2.3",
     "eazy-logger": "^3.1.0",
     "emoji-mart": "npm:panta82-emoji-mart@^3.0.1",
     "eslint-plugin-cypress": "^2.12.1",

+ 6 - 0
apps/app/public/static/locales/en_US/commons.json

@@ -42,6 +42,12 @@
     }
   },
 
+  "search_method_menu_item": {
+    "search_in_all": "Search in all",
+    "only_children_of_this_tree": "Only children of this tree",
+    "exact_mutch": "Exact match"
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",

+ 6 - 0
apps/app/public/static/locales/ja_JP/commons.json

@@ -44,6 +44,12 @@
     }
   },
 
+  "search_method_menu_item": {
+    "search_in_all": "全てのページ",
+    "only_children_of_this_tree": "この階層下の子ページのみ",
+    "exact_mutch": "キーワードに完全一致した文字を含むページのみ"
+  },
+
   "share_links": {
     "Share Link": "共有用リンク",
     "Page Path": "ページパス",

+ 6 - 0
apps/app/public/static/locales/zh_CN/commons.json

@@ -45,6 +45,12 @@
 		}
   },
 
+  "search_method_menu_item": {
+    "search_in_all": "所有页面",
+    "only_children_of_this_tree": "当前分支以下内容",
+    "exact_mutch": "完全匹配"
+  },
+
   "share_links": {
     "Share Link": "Share Link",
     "Page Path": "Page Path",

+ 2 - 0
apps/app/src/components/Layout/BasicLayout.tsx

@@ -23,6 +23,7 @@ const PageRenameModal = dynamic(() => import('../PageRenameModal'), { ssr: false
 const PagePresentationModal = dynamic(() => import('../PagePresentationModal'), { ssr: false });
 const PageAccessoriesModal = dynamic(() => import('../PageAccessoriesModal').then(mod => mod.PageAccessoriesModal), { ssr: false });
 const DeleteBookmarkFolderModal = dynamic(() => import('../DeleteBookmarkFolderModal').then(mod => mod.DeleteBookmarkFolderModal), { ssr: false });
+const SearchModal = dynamic(() => import('../../features/search/client/components/SearchModal'), { ssr: false });
 
 
 type Props = {
@@ -57,6 +58,7 @@ export const BasicLayout = ({ children, className }: Props): JSX.Element => {
         <DeleteAttachmentModal />
         <DeleteBookmarkFolderModal />
         <PutbackPageModal />
+        <SearchModal />
       </DndProvider>
 
       <PagePresentationModal />

+ 4 - 15
apps/app/src/components/Navbar/GrowiNavbarBottom.tsx

@@ -1,11 +1,10 @@
 import React from 'react';
 
+import { useSearchModal } from '~/features/search/client/stores/search';
 import { useIsSearchPage } from '~/stores/context';
 import { usePageCreateModal } from '~/stores/modal';
 import { useCurrentPagePath } from '~/stores/page';
-import { useIsDeviceLargerThanMd, useDrawerOpened } from '~/stores/ui';
-
-import { GlobalSearch } from './GlobalSearch';
+import { useDrawerOpened } from '~/stores/ui';
 
 import styles from './GrowiNavbarBottom.module.scss';
 
@@ -13,10 +12,10 @@ import styles from './GrowiNavbarBottom.module.scss';
 export const GrowiNavbarBottom = (): JSX.Element => {
 
   const { data: isDrawerOpened, mutate: mutateDrawerOpened } = useDrawerOpened();
-  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
   const { open: openCreateModal } = usePageCreateModal();
   const { data: currentPagePath } = useCurrentPagePath();
   const { data: isSearchPage } = useIsSearchPage();
+  const { open: openSearchModal } = useSearchModal();
 
   return (
     <div className={`
@@ -24,15 +23,6 @@ export const GrowiNavbarBottom = (): JSX.Element => {
       ${isDrawerOpened ? styles['grw-navbar-bottom-drawer-opened'] : ''}
       d-md-none d-edit-none d-print-none fixed-bottom`}
     >
-
-      { !isDeviceLargerThanMd && !isSearchPage && (
-        <div id="grw-global-search-collapse" className="grw-global-search collapse bg-dark">
-          <div className="p-3">
-            <GlobalSearch dropup />
-          </div>
-        </div>
-      ) }
-
       <div className="navbar navbar-expand px-4 px-sm-5">
 
         <ul className="navbar-nav flex-grow-1 d-flex align-items-center justify-content-between">
@@ -62,8 +52,7 @@ export const GrowiNavbarBottom = (): JSX.Element => {
                 <a
                   role="button"
                   className="nav-link btn-lg"
-                  data-bs-target="#grw-global-search-collapse"
-                  data-bs-toggle="collapse"
+                  onClick={openSearchModal}
                 >
                   <span className="material-symbols-outlined fs-2">search</span>
                 </a>

+ 6 - 1
apps/app/src/components/PageControls/PageControls.tsx

@@ -15,7 +15,7 @@ import {
 import { toastError } from '~/client/util/toastr';
 import { useIsGuestUser, useIsReadOnlyUser } from '~/stores/context';
 import { useTagEditModal, type IPageForPageDuplicateModal } from '~/stores/modal';
-import { EditorMode, useEditorMode } from '~/stores/ui';
+import { EditorMode, useEditorMode, useIsDeviceLargerThanMd } from '~/stores/ui';
 import loggerFactory from '~/utils/logger';
 
 import { useSWRxPageInfo, useSWRxTagsInfo } from '../../stores/page';
@@ -27,6 +27,7 @@ import {
 
 import { BookmarkButtons } from './BookmarkButtons';
 import LikeButtons from './LikeButtons';
+import SearchButton from './SearchButton';
 import SeenUserInfo from './SeenUserInfo';
 import SubscribeButton from './SubscribeButton';
 
@@ -123,6 +124,7 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
   const { data: isGuestUser } = useIsGuestUser();
   const { data: isReadOnlyUser } = useIsReadOnlyUser();
   const { data: editorMode } = useEditorMode();
+  const { data: isDeviceLargerThanMd } = useIsDeviceLargerThanMd();
 
   const { mutate: mutatePageInfo } = useSWRxPageInfo(pageId, shareLinkId);
 
@@ -250,6 +252,9 @@ const PageControlsSubstance = (props: PageControlsSubstanceProps): JSX.Element =
 
   return (
     <div className={`grw-page-controls ${styles['grw-page-controls']} d-flex`} style={{ gap: '2px' }}>
+      { isDeviceLargerThanMd && (
+        <SearchButton />
+      )}
       {revisionId != null && !isViewMode && (
         <Tags
           onClickEditTagsButton={onClickEditTagsButton}

+ 14 - 0
apps/app/src/components/PageControls/SearchButton.module.scss

@@ -0,0 +1,14 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '@growi/ui/scss/atoms/btn-muted';
+
+@use './button-styles';
+
+.btn-search :global {
+  @extend %btn-basis;
+}
+
+// == Colors
+.btn-search {
+  @include btn-muted.colorize(bs.$success);
+}

+ 28 - 0
apps/app/src/components/PageControls/SearchButton.tsx

@@ -0,0 +1,28 @@
+import React, { useCallback } from 'react';
+
+import { useSearchModal } from '../../features/search/client/stores/search';
+
+import styles from './SearchButton.module.scss';
+
+
+const SearchButton = (): JSX.Element => {
+
+  const { open: openSearchModal } = useSearchModal();
+
+  const searchButtonClickHandler = useCallback(() => {
+    openSearchModal();
+  }, [openSearchModal]);
+
+
+  return (
+    <button
+      type="button"
+      className={`me-3 btn btn-search ${styles['btn-search']}`}
+      onClick={searchButtonClickHandler}
+    >
+      <span className="material-symbols-outlined">search</span>
+    </button>
+  );
+};
+
+export default SearchButton;

+ 1 - 1
apps/app/src/components/Sidebar/AppTitle/AppTitle.module.scss

@@ -38,7 +38,7 @@
 // == App title truncation
 .on-subnavigation {
   // set width for truncation
-  $grw-page-controls-width: 226px;
+  $grw-page-controls-width: 280px;
   $grw-page-editor-mode-manager-width: 90px;
   $grw-contextual-subnavigation-padding-right: 12px;
   $gap: 8px;

+ 58 - 0
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -0,0 +1,58 @@
+import React, {
+  useCallback, useRef, useEffect, useMemo,
+} from 'react';
+
+import { GetInputProps } from '../interfaces/downshift';
+
+type Props = {
+  searchKeyword: string,
+  onChange?: (text: string) => void,
+  onSubmit?: () => void,
+  getInputProps: GetInputProps,
+}
+
+export const SearchForm = (props: Props): JSX.Element => {
+  const {
+    searchKeyword, onChange, onSubmit, getInputProps,
+  } = props;
+
+  const inputRef = useRef<HTMLInputElement>(null);
+
+  const changeSearchTextHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
+    onChange?.(e.target.value);
+  }, [onChange]);
+
+  const submitHandler = useCallback((e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+
+    const isEmptyKeyword = searchKeyword.trim().length === 0;
+    if (isEmptyKeyword) {
+      return;
+    }
+
+    onSubmit?.();
+  }, [searchKeyword, onSubmit]);
+
+  const inputOptions = useMemo(() => {
+    return getInputProps({
+      type: 'search',
+      placeholder: 'Search...',
+      className: 'form-control',
+      ref: inputRef,
+      value: searchKeyword,
+      onChange: changeSearchTextHandler,
+    });
+  }, [getInputProps, searchKeyword, changeSearchTextHandler]);
+
+  useEffect(() => {
+    if (inputRef.current != null) {
+      inputRef.current.focus();
+    }
+  });
+
+  return (
+    <form className="w-100" onSubmit={submitHandler}>
+      <input {...inputOptions} />
+    </form>
+  );
+};

+ 60 - 0
apps/app/src/features/search/client/components/SearchHelp.tsx

@@ -0,0 +1,60 @@
+import React, { useState } from 'react';
+
+import { useTranslation } from 'next-i18next';
+import { Collapse } from 'reactstrap';
+
+export const SearchHelp = (): JSX.Element => {
+  const { t } = useTranslation();
+
+  const [isOpen, setIsOpen] = useState(false);
+
+  return (
+    <>
+      <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center ms-1 p-0" onClick={() => setIsOpen(!isOpen)}>
+        <span className="material-symbols-outlined me-2">help</span>
+        { t('search_help.title') }
+        <span className="material-symbols-outlined ms-2">{isOpen ? 'expand_less' : 'expand_more'}</span>
+      </button>
+      <Collapse isOpen={isOpen}>
+        <table className="table m-0">
+          <tbody>
+            <tr>
+              <th className="py-2">
+                <code>word1</code> <code>word2</code><br />
+                <small className="text-muted">({ t('search_help.and.syntax help') })</small>
+              </th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.and.desc', { word1: 'word1', word2: 'word2' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2">
+                <code>&quot;This is GROWI&quot;</code><br />
+                <small className="text-muted">({ t('search_help.phrase.syntax help') })</small>
+              </th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.phrase.desc', { phrase: 'This is GROWI' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-keyword</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude.desc', { word: 'keyword' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>prefix:/user/</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.prefix.desc', { path: '/user/' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-prefix:/user/</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_prefix.desc', { path: '/user/' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>tag:wiki</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.tag.desc', { tag: 'wiki' }) }</h6></td>
+            </tr>
+            <tr>
+              <th className="py-2"><code>-tag:wiki</code></th>
+              <td><h6 className="m-0 text-muted">{ t('search_help.exclude_tag.desc', { tag: 'wiki' }) }</h6></td>
+            </tr>
+          </tbody>
+        </table>
+      </Collapse>
+    </>
+  );
+};

+ 34 - 0
apps/app/src/features/search/client/components/SearchMenuItem.module.scss

@@ -0,0 +1,34 @@
+@use '@growi/core/scss/bootstrap/init' as bs;
+
+@use '~/styles/variables' as var;
+
+.search-menu-item :global {
+  li {
+    cursor: pointer;
+  }
+}
+
+// == Colors
+@include bs.color-mode(light) {
+  .search-menu-item :global {
+    li.active {
+      background-color: var(--grw-primary-100)
+    }
+
+    li:hover {
+      background-color: bs.$gray-200;
+    }
+  }
+}
+
+@include bs.color-mode(dark) {
+  .search-menu-item :global {
+    li.active {
+      background-color: var(--grw-primary-800)
+    }
+
+    li:hover {
+      background-color: bs.$gray-800;
+    }
+  }
+}

+ 35 - 0
apps/app/src/features/search/client/components/SearchMenuItem.tsx

@@ -0,0 +1,35 @@
+import React from 'react';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import styles from './SearchMenuItem.module.scss';
+
+type Props = {
+  url: string
+  index: number
+  isActive: boolean
+  getItemProps: GetItemProps
+  children: React.ReactNode
+}
+
+export const SearchMenuItem = (props: Props): JSX.Element => {
+  const {
+    url, index, isActive, getItemProps, children,
+  } = props;
+
+  const itemMenuOptions = (
+    getItemProps({
+      index,
+      item: { url },
+      className: `d-flex p-1 text-muted ${isActive ? 'active' : ''}`,
+    })
+  );
+
+  return (
+    <div className={`search-menu-item ${styles['search-menu-item']}`}>
+      <li {...itemMenuOptions}>
+        { children }
+      </li>
+    </div>
+  );
+};

+ 75 - 0
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -0,0 +1,75 @@
+import React from 'react';
+
+import { useTranslation } from 'next-i18next';
+
+import { useCurrentPagePath } from '~/stores/page';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import { SearchMenuItem } from './SearchMenuItem';
+
+type Props = {
+  activeIndex: number | null
+  searchKeyword: string
+  getItemProps: GetItemProps
+}
+
+export const SearchMethodMenuItem = (props: Props): JSX.Element => {
+  const {
+    activeIndex, searchKeyword, getItemProps,
+  } = props;
+
+  const { t } = useTranslation('commons');
+
+  const { data: currentPagePath } = useCurrentPagePath();
+
+  const shouldShowMenuItem = searchKeyword.trim().length > 0;
+
+  return (
+    <>
+      { shouldShowMenuItem && (
+        <SearchMenuItem
+          index={0}
+          isActive={activeIndex === 0}
+          getItemProps={getItemProps}
+          url={`/_search?q=${searchKeyword}`}
+        >
+          <span className="material-symbols-outlined fs-4 me-3">search</span>
+          <span>{searchKeyword}</span>
+          <div className="ms-auto">
+            <span>{t('search_method_menu_item.search_in_all')}</span>
+          </div>
+        </SearchMenuItem>
+      )}
+
+      <SearchMenuItem
+        index={shouldShowMenuItem ? 1 : 0}
+        isActive={activeIndex === (shouldShowMenuItem ? 1 : 0)}
+        getItemProps={getItemProps}
+        url={`/_search?q=prefix:${currentPagePath} ${searchKeyword}`}
+      >
+        <span className="material-symbols-outlined fs-4 me-3">search</span>
+        <code>prefix: {currentPagePath}</code>
+        <span className="ms-2">{searchKeyword}</span>
+        <div className="ms-auto">
+          <span>{t('search_method_menu_item.only_children_of_this_tree')}</span>
+        </div>
+      </SearchMenuItem>
+
+      { shouldShowMenuItem && (
+        <SearchMenuItem
+          index={2}
+          isActive={activeIndex === 2}
+          getItemProps={getItemProps}
+          url={`/_search?q="${searchKeyword}"`}
+        >
+          <span className="material-symbols-outlined fs-4 me-3">search</span>
+          <span>{`"${searchKeyword}"`}</span>
+          <div className="ms-auto">
+            <span>{t('search_method_menu_item.exact_mutch')}</span>
+          </div>
+        </SearchMenuItem>
+      ) }
+    </>
+  );
+};

+ 113 - 0
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -0,0 +1,113 @@
+
+import React, {
+  useState, useCallback, useEffect,
+} from 'react';
+
+import Downshift, { type DownshiftState, type StateChangeOptions } from 'downshift';
+import { useRouter } from 'next/router';
+import { Modal, ModalBody } from 'reactstrap';
+
+import type { DownshiftItem } from '../interfaces/downshift';
+import { useSearchModal } from '../stores/search';
+
+import { SearchForm } from './SearchForm';
+import { SearchHelp } from './SearchHelp';
+import { SearchMethodMenuItem } from './SearchMethodMenuItem';
+import { SearchResultMenuItem } from './SearchResultMenuItem';
+
+const SearchModal = (): JSX.Element => {
+  const [searchKeyword, setSearchKeyword] = useState('');
+
+  const { data: searchModalData, close: closeSearchModal } = useSearchModal();
+
+  const router = useRouter();
+
+  const changeSearchTextHandler = useCallback((searchText: string) => {
+    setSearchKeyword(searchText);
+  }, []);
+
+  const selectSearchMenuItemHandler = useCallback((selectedItem: DownshiftItem) => {
+    router.push(selectedItem.url);
+    closeSearchModal();
+  }, [closeSearchModal, router]);
+
+  const submitHandler = useCallback(() => {
+    router.push(`/_search?q=${searchKeyword}`);
+    closeSearchModal();
+  }, [closeSearchModal, router, searchKeyword]);
+
+  const stateReducer = (state: DownshiftState<DownshiftItem>, changes: StateChangeOptions<DownshiftItem>) => {
+    // Do not update highlightedIndex on mouse hover
+    if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
+      return {
+        ...changes,
+        highlightedIndex: state.highlightedIndex,
+      };
+    }
+
+    return changes;
+  };
+
+  useEffect(() => {
+    if (!searchModalData?.isOpened) {
+      setSearchKeyword('');
+    }
+  }, [searchModalData?.isOpened]);
+
+  return (
+    <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal}>
+      <ModalBody>
+        <Downshift
+          onSelect={selectSearchMenuItemHandler}
+          stateReducer={stateReducer}
+          defaultIsOpen
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            getItemProps,
+            getMenuProps,
+            highlightedIndex,
+          }) => (
+            <div {...getRootProps({}, { suppressRefError: true })}>
+              <div className="text-muted d-flex justify-content-center align-items-center p-1">
+                <span className="material-symbols-outlined fs-4 me-3">search</span>
+                <SearchForm
+                  searchKeyword={searchKeyword}
+                  onChange={changeSearchTextHandler}
+                  onSubmit={submitHandler}
+                  getInputProps={getInputProps}
+                />
+                <button
+                  type="button"
+                  className="btn border-0 d-flex justify-content-center p-0"
+                  onClick={closeSearchModal}
+                >
+                  <span className="material-symbols-outlined fs-4 ms-3">close</span>
+                </button>
+              </div>
+
+              <ul {...getMenuProps()} className="list-unstyled">
+                <div className="border-top mt-3 mb-2" />
+                <SearchMethodMenuItem
+                  activeIndex={highlightedIndex}
+                  searchKeyword={searchKeyword}
+                  getItemProps={getItemProps}
+                />
+                <div className="border-top mt-2 mb-2" />
+                <SearchResultMenuItem
+                  activeIndex={highlightedIndex}
+                  searchKeyword={searchKeyword}
+                  getItemProps={getItemProps}
+                />
+              </ul>
+            </div>
+          )}
+        </Downshift>
+        <SearchHelp />
+      </ModalBody>
+    </Modal>
+  );
+};
+
+export default SearchModal;

+ 75 - 0
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -0,0 +1,75 @@
+import React, { useCallback } from 'react';
+
+import { PagePathLabel, UserPicture } from '@growi/ui/dist/components';
+import { useDebounce } from 'usehooks-ts';
+
+import { useSWRxSearch } from '~/stores/search';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+import { SearchMenuItem } from './SearchMenuItem';
+
+type Props = {
+  activeIndex: number | null,
+  searchKeyword: string,
+  getItemProps: GetItemProps,
+}
+export const SearchResultMenuItem = (props: Props): JSX.Element => {
+  const { activeIndex, searchKeyword, getItemProps } = props;
+
+  const debouncedKeyword = useDebounce(searchKeyword, 500);
+
+  const isEmptyKeyword = searchKeyword.trim().length === 0;
+
+  const { data: searchResult, isLoading } = useSWRxSearch(isEmptyKeyword ? null : debouncedKeyword, null, { limit: 10 });
+
+  /**
+   *  SearchMenu is a combination of a list of SearchMethodMenuItem and SearchResultMenuItem (this component).
+   *  If no keywords are entered into SearchForm, SearchMethodMenuItem returns a single item. Conversely, when keywords are entered, three items are returned.
+   *  For these reasons, the starting index of SearchResultMemuItem changes depending on the presence or absence of the searchKeyword.
+   */
+  const getFiexdIndex = useCallback((index: number) => {
+    return (isEmptyKeyword ? 1 : 3) + index;
+  }, [isEmptyKeyword]);
+
+  if (isLoading) {
+    return (
+      <>
+        Searching...
+        <div className="border-top mt-3" />
+      </>
+    );
+  }
+
+  if (isEmptyKeyword || searchResult == null || searchResult.data.length === 0) {
+    return <></>;
+  }
+
+  return (
+    <>
+      {searchResult?.data
+        .map((item, index) => (
+          <SearchMenuItem
+            key={item.data._id}
+            index={getFiexdIndex(index)}
+            isActive={getFiexdIndex(index) === activeIndex}
+            getItemProps={getItemProps}
+            url={item.data._id}
+          >
+            <UserPicture user={item.data.creator} />
+
+            <span className="ms-3 text-break text-wrap">
+              <PagePathLabel path={item.data.path} />
+            </span>
+
+            <span className="ms-2 d-flex justify-content-center align-items-center">
+              <span className="material-symbols-outlined fs-5">footprint</span>
+              <span>{item.data.seenUsers.length}</span>
+            </span>
+          </SearchMenuItem>
+        ))
+      }
+      <div className="border-top mt-2 mb-2" />
+    </>
+  );
+};

+ 6 - 0
apps/app/src/features/search/client/interfaces/downshift.ts

@@ -0,0 +1,6 @@
+import type { ControllerStateAndHelpers } from 'downshift';
+
+export type DownshiftItem = { url: string };
+
+export type GetItemProps = ControllerStateAndHelpers<DownshiftItem>['getItemProps']
+export type GetInputProps = ControllerStateAndHelpers<DownshiftItem>['getInputProps']

+ 22 - 0
apps/app/src/features/search/client/stores/search.ts

@@ -0,0 +1,22 @@
+import { SWRResponse } from 'swr';
+
+import { useStaticSWR } from '~/stores/use-static-swr';
+
+type SearchModalStatus = {
+  isOpened: boolean,
+}
+
+type SearchModalUtils = {
+  open(): void
+  close(): void
+}
+export const useSearchModal = (status?: SearchModalStatus): SWRResponse<SearchModalStatus, Error> & SearchModalUtils => {
+  const initialStatus = { isOpened: false };
+  const swrResponse = useStaticSWR<SearchModalStatus, Error>('SearchModal', status, { fallbackData: initialStatus });
+
+  return {
+    ...swrResponse,
+    open: () => swrResponse.mutate({ isOpened: true }),
+    close: () => swrResponse.mutate({ isOpened: false }),
+  };
+};

+ 24 - 1
yarn.lock

@@ -1189,6 +1189,13 @@
   dependencies:
     regenerator-runtime "^0.14.0"
 
+"@babel/runtime@^7.22.15":
+  version "7.23.6"
+  resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.6.tgz#c05e610dc228855dc92ef1b53d07389ed8ab521d"
+  integrity sha512-zHd0eUrf5GZoOWVCXp6koAKQTfZV07eit6bGPmJgnZdnSAvvZee6zniW2XMF7Cmc4ISOOnPy3QaSiIJGJkVEDQ==
+  dependencies:
+    regenerator-runtime "^0.14.0"
+
 "@babel/template@^7.22.5", "@babel/template@^7.3.3":
   version "7.22.5"
   resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.22.5.tgz#0c8c4d944509875849bd0344ff0050756eefc6ec"
@@ -5974,6 +5981,11 @@ compute-scroll-into-view@^1.0.17:
   resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.17.tgz#6a88f18acd9d42e9cf4baa6bec7e0522607ab7ab"
   integrity sha512-j4dx+Fb0URmzbwwMUrhqWM2BEWHdFGx+qZ9qqASHRPqvTYdqvWnHg0H1hIbcyLnvgnoNAVMlwkepyqM3DaIFUg==
 
+compute-scroll-into-view@^3.0.3:
+  version "3.1.0"
+  resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-3.1.0.tgz#753f11d972596558d8fe7c6bcbc8497690ab4c87"
+  integrity sha512-rj8l8pD4bJ1nx+dAkMhV1xB5RuZEyVysfxJqB1pRchh1KVvwOv9b7CGB8ZfjTImVv2oF+sYMUkMZq6Na5Ftmbg==
+
 concat-map@0.0.1:
   version "0.0.1"
   resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
@@ -7025,6 +7037,17 @@ dotignore@^0.1.2:
   dependencies:
     minimatch "^3.0.4"
 
+downshift@^8.2.3:
+  version "8.2.3"
+  resolved "https://registry.yarnpkg.com/downshift/-/downshift-8.2.3.tgz#27106a5d9f408a6f6f9350ca465801d07e52db87"
+  integrity sha512-1HkvqaMTZpk24aqnXaRDnT+N5JCbpFpW+dCogB11+x+FCtfkFX0MbAO4vr/JdXi1VYQF174KjNUveBXqaXTPtg==
+  dependencies:
+    "@babel/runtime" "^7.22.15"
+    compute-scroll-into-view "^3.0.3"
+    prop-types "^15.8.1"
+    react-is "^18.2.0"
+    tslib "^2.6.2"
+
 dtrace-provider@~0.8:
   version "0.8.8"
   resolved "https://registry.yarnpkg.com/dtrace-provider/-/dtrace-provider-0.8.8.tgz#2996d5490c37e1347be263b423ed7b297fb0d97e"
@@ -13505,7 +13528,7 @@ react-is@^17.0.1:
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0"
   integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==
 
-react-is@^18.0.0:
+react-is@^18.0.0, react-is@^18.2.0:
   version "18.2.0"
   resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b"
   integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==