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

Merge pull request #8325 from weseek/feat/136096-implementation-of-the-behavior-for-the-search-menu4

feat: Implementation of the behavior for the search menu (using Downshift)
Shun Miyazawa 2 лет назад
Родитель
Сommit
aed7e3d1a1

+ 1 - 0
apps/app/package.json

@@ -236,6 +236,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",

+ 33 - 32
apps/app/src/features/search/client/components/SearchForm.tsx

@@ -1,30 +1,48 @@
 import React, {
-  useCallback, useRef, useEffect,
+  useCallback, useRef, useEffect, useMemo,
 } from 'react';
 
+import { GetInputProps } from '../interfaces/downshift';
+
 type Props = {
   searchKeyword: string,
-  onChangeSearchText?: (text: string) => void,
-  onClickClearButton?: () => void,
+  onChange?: (text: string) => void,
+  onSubmit?: () => void,
+  getInputProps: GetInputProps,
 }
+
 export const SearchForm = (props: Props): JSX.Element => {
   const {
-    searchKeyword, onChangeSearchText, onClickClearButton,
+    searchKeyword, onChange, onSubmit, getInputProps,
   } = props;
 
   const inputRef = useRef<HTMLInputElement>(null);
 
   const changeSearchTextHandler = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
-    if (onChangeSearchText != null) {
-      onChangeSearchText(e.target.value);
-    }
-  }, [onChangeSearchText]);
+    onChange?.(e.target.value);
+  }, [onChange]);
 
-  const clickClearButtonHandler = useCallback(() => {
-    if (onClickClearButton != null) {
-      onClickClearButton();
+  const submitHandler = useCallback((e: React.FormEvent<HTMLFormElement>) => {
+    e.preventDefault();
+
+    const isEmptyKeyword = searchKeyword.trim().length === 0;
+    if (isEmptyKeyword) {
+      return;
     }
-  }, [onClickClearButton]);
+
+    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) {
@@ -33,25 +51,8 @@ export const SearchForm = (props: Props): JSX.Element => {
   });
 
   return (
-    <div className="text-muted d-flex justify-content-center align-items-center ps-1">
-      <span className="material-symbols-outlined fs-4 me-3">search</span>
-
-      <input
-        ref={inputRef}
-        type="text"
-        className="form-control"
-        placeholder="Search..."
-        value={searchKeyword}
-        onChange={(e) => { changeSearchTextHandler(e) }}
-      />
-
-      <button
-        type="button"
-        className="btn border-0 d-flex justify-content-center p-0"
-        onClick={clickClearButtonHandler}
-      >
-        <span className="material-symbols-outlined fs-4 ms-3">close</span>
-      </button>
-    </div>
+    <form className="w-100" onSubmit={submitHandler}>
+      <input {...inputOptions} />
+    </form>
   );
 };

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

@@ -10,7 +10,7 @@ export const SearchHelp = (): JSX.Element => {
 
   return (
     <>
-      <button type="button" className="btn border-0 text-muted d-flex justify-content-center align-items-center  ps-1" onClick={() => setIsOpen(!isOpen)}>
+      <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>

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

@@ -0,0 +1,33 @@
+import React from 'react';
+
+import type { GetItemProps } from '../interfaces/downshift';
+
+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 },
+      // TOOD: https://redmine.weseek.co.jp/issues/137235
+      style: { backgroundColor: isActive ? 'lightblue' : 'white', cursor: 'pointer' },
+      className: 'text-muted d-flex p-1',
+    })
+  );
+
+  return (
+    <li {...itemMenuOptions}>
+      { children }
+    </li>
+  );
+};

+ 54 - 47
apps/app/src/features/search/client/components/SearchMethodMenuItem.tsx

@@ -4,65 +4,72 @@ import { useTranslation } from 'next-i18next';
 
 import { useCurrentPagePath } from '~/stores/page';
 
-type MenuItemProps = {
-  children: React.ReactNode
-  onClick: () => void
-}
-const MenuItem = (props: MenuItemProps): JSX.Element => {
-  const { children, onClick } = props;
-
-  return (
-    <tr>
-      <div className="text-muted ps-1 d-flex">
-        <span className="material-symbols-outlined fs-4 me-3">search</span>
-        { children }
-      </div>
-    </tr>
-  );
-};
+import type { GetItemProps } from '../interfaces/downshift';
 
+import { SearchMenuItem } from './SearchMenuItem';
 
-type SearchMethodMenuItemProps = {
+type Props = {
+  activeIndex: number | null
   searchKeyword: string
+  getItemProps: GetItemProps
 }
-export const SearchMethodMenuItem = (props: SearchMethodMenuItemProps): JSX.Element => {
+
+export const SearchMethodMenuItem = (props: Props): JSX.Element => {
+  const {
+    activeIndex, searchKeyword, getItemProps,
+  } = props;
+
   const { t } = useTranslation('commons');
 
   const { data: currentPagePath } = useCurrentPagePath();
 
-  const { searchKeyword } = props;
-
-  const shouldShowButton = searchKeyword.length > 0;
+  const shouldShowMenuItem = searchKeyword.trim().length > 0;
 
   return (
-    <table className="table">
-      <tbody>
-        { shouldShowButton && (
-          <MenuItem onClick={() => {}}>
-            <span>{searchKeyword}</span>
-            <div className="ms-auto">
-              <span>{t('search_method_menu_item.search_in_all')}</span>
-            </div>
-          </MenuItem>
-        )}
-
-        <MenuItem onClick={() => {}}>
-          <code>prefix: {currentPagePath}</code>
-          <span className="ms-2">{searchKeyword}</span>
+    <>
+      { 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.only_children_of_this_tree')}</span>
+            <span>{t('search_method_menu_item.search_in_all')}</span>
           </div>
-        </MenuItem>
+        </SearchMenuItem>
+      )}
 
-        { shouldShowButton && (
-          <MenuItem onClick={() => {}}>
-            <span>{`"${searchKeyword}"`}</span>
-            <div className="ms-auto">
-              <span>{t('search_method_menu_item.exact_mutch')}</span>
-            </div>
-          </MenuItem>
-        ) }
-      </tbody>
-    </table>
+      <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>
+      ) }
+    </>
   );
 };

+ 63 - 12
apps/app/src/features/search/client/components/SearchModal.tsx

@@ -1,9 +1,13 @@
+
 import React, {
   useState, useCallback, useEffect,
 } from 'react';
 
+import Downshift 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';
@@ -16,13 +20,21 @@ const SearchModal = (): JSX.Element => {
 
   const { data: searchModalData, close: closeSearchModal } = useSearchModal();
 
+  const router = useRouter();
+
   const changeSearchTextHandler = useCallback((searchText: string) => {
     setSearchKeyword(searchText);
   }, []);
 
-  const clickClearButtonHandler = useCallback(() => {
-    setSearchKeyword('');
-  }, []);
+  const selectSearchMenuItemHandler = useCallback((selectedItem: DownshiftItem) => {
+    router.push(selectedItem.url);
+    closeSearchModal();
+  }, [closeSearchModal, router]);
+
+  const submitHandler = useCallback(() => {
+    router.push(`/_search?q=${searchKeyword}`);
+    closeSearchModal();
+  }, [closeSearchModal, router, searchKeyword]);
 
   useEffect(() => {
     if (!searchModalData?.isOpened) {
@@ -33,15 +45,54 @@ const SearchModal = (): JSX.Element => {
   return (
     <Modal size="lg" isOpen={searchModalData?.isOpened ?? false} toggle={closeSearchModal}>
       <ModalBody>
-        <SearchForm
-          searchKeyword={searchKeyword}
-          onChangeSearchText={changeSearchTextHandler}
-          onClickClearButton={clickClearButtonHandler}
-        />
-        <div className="border-top mt-3 mb-3" />
-        <SearchMethodMenuItem searchKeyword={searchKeyword} />
-        <div className="border-top mt-2 mb-2" />
-        <SearchResultMenuItem searchKeyword={searchKeyword} />
+        <Downshift
+          onSelect={selectSearchMenuItemHandler}
+          defaultIsOpen
+        >
+          {({
+            getRootProps,
+            getInputProps,
+            getItemProps,
+            getMenuProps,
+            highlightedIndex,
+            setHighlightedIndex,
+          }) => (
+            <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>
+
+              {/* see: https://github.com/downshift-js/downshift/issues/582#issuecomment-423592531 */}
+              <ul {...getMenuProps({ onMouseLeave: () => { setHighlightedIndex(-1) } })} 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>

+ 42 - 26
apps/app/src/features/search/client/components/SearchResultMenuItem.tsx

@@ -1,28 +1,42 @@
-import React from 'react';
+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 { searchKeyword } = props;
+  const { activeIndex, searchKeyword, getItemProps } = props;
 
   const debouncedKeyword = useDebounce(searchKeyword, 500);
 
-  const isEmptyKeyword = debouncedKeyword.trim() === '';
+  const isEmptyKeyword = searchKeyword.trim().length === 0;
+
+  const { data: searchResult, isLoading } = useSWRxSearch(isEmptyKeyword ? null : debouncedKeyword, null, { limit: 10 });
 
-  const { data: searchResult, isLoading } = useSWRxSearch(isEmptyKeyword ? null : searchKeyword, 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-2" />
+        <div className="border-top mt-3" />
       </>
     );
   }
@@ -33,27 +47,29 @@ export const SearchResultMenuItem = (props: Props): JSX.Element => {
 
   return (
     <>
-      <table>
-        <tbody>
-          {searchResult.data?.map(pageWithMeta => (
-            <tr key={pageWithMeta.data._id}>
-              <div className="ps-1 mb-2 d-flex">
-                <UserPicture user={pageWithMeta.data.creator} />
-
-                <span className="ms-3 text-break text-wrap">
-                  <PagePathLabel path={pageWithMeta.data.path} />
-                </span>
-
-                <span className="ms-2 text-muted d-flex justify-content-center align-items-center">
-                  <span className="material-symbols-outlined fs-5">footprint</span>
-                  <span>{pageWithMeta.data.seenUsers.length}</span>
-                </span>
-              </div>
-            </tr>
-          ))}
-        </tbody>
-      </table>
-      <div className="border-top mb-2" />
+      {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 text-muted 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']

+ 24 - 1
yarn.lock

@@ -1248,6 +1248,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"
@@ -6060,6 +6067,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"
@@ -7111,6 +7123,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"
@@ -13644,7 +13667,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==