From d84edd9f6ae0a2f53a1313d579408d28d3dd758a Mon Sep 17 00:00:00 2001 From: Anand Gorantala Date: Wed, 21 Aug 2024 01:31:49 +0900 Subject: [PATCH] feat: Use floating-ui/react for the autosuggest input in the pixel code sample (#24) Co-authored-by: Anand Gorantala --- examples/pixel/.eslintrc.json | 1 + examples/pixel/package-lock.json | 45 ++- examples/pixel/package.json | 1 + examples/pixel/src/components/SearchBar.jsx | 362 +++++++++++++------- 4 files changed, 275 insertions(+), 134 deletions(-) diff --git a/examples/pixel/.eslintrc.json b/examples/pixel/.eslintrc.json index b01377b..e1edb1b 100644 --- a/examples/pixel/.eslintrc.json +++ b/examples/pixel/.eslintrc.json @@ -5,6 +5,7 @@ ], "rules": { "camelcase": "off", + "react/jsx-props-no-spreading": "off", "react/react-in-jsx-scope": "off", "react/prop-types": "off" } diff --git a/examples/pixel/package-lock.json b/examples/pixel/package-lock.json index 92a5835..7bc3283 100644 --- a/examples/pixel/package-lock.json +++ b/examples/pixel/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@bloomreach/discovery-web-sdk": "^1.1.0", "@bloomreach/react-banana-ui": "1.20.0", + "@floating-ui/react": "^0.26.22", "@mui/base": "^5.0.0-beta.40", "@uiw/react-json-view": "^2.0.0-alpha.25", "lodash": "^4.17.21", @@ -192,6 +193,20 @@ } } }, + "node_modules/@bloomreach/react-banana-ui/node_modules/@floating-ui/react": { + "version": "0.26.9", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz", + "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==", + "dependencies": { + "@floating-ui/react-dom": "^2.0.8", + "@floating-ui/utils": "^0.2.1", + "tabbable": "^6.0.1" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/@bloomreach/react-banana-ui/node_modules/@mui/base": { "version": "5.0.0-beta.37", "resolved": "https://registry.npmjs.org/@mui/base/-/base-5.0.0-beta.37.tgz", @@ -297,13 +312,13 @@ } }, "node_modules/@floating-ui/react": { - "version": "0.26.9", - "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.9.tgz", - "integrity": "sha512-p86wynZJVEkEq2BBjY/8p2g3biQ6TlgT4o/3KgFKyTWoJLU1GZ8wpctwRqtkEl2tseYA+kw7dBAIDFcednfI5w==", + "version": "0.26.22", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.26.22.tgz", + "integrity": "sha512-LNv4azPt8SpT4WW7Kku5JNVjLk2GcS0bGGjFTAgqOONRFo9r/aaGHHPpdiIuQbB1t8shmWyWqTTUDmZ9fcNshg==", "dependencies": { - "@floating-ui/react-dom": "^2.0.8", - "@floating-ui/utils": "^0.2.1", - "tabbable": "^6.0.1" + "@floating-ui/react-dom": "^2.1.1", + "@floating-ui/utils": "^0.2.7", + "tabbable": "^6.0.0" }, "peerDependencies": { "react": ">=16.8.0", @@ -2956,9 +2971,9 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" @@ -4270,9 +4285,9 @@ } }, "node_modules/postcss-selector-parser": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.1.tgz", - "integrity": "sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==", + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", "dev": true, "dependencies": { "cssesc": "^3.0.0", @@ -5047,9 +5062,9 @@ "integrity": "sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==" }, "node_modules/tailwindcss": { - "version": "3.4.9", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.9.tgz", - "integrity": "sha512-1SEOvRr6sSdV5IDf9iC+NU4dhwdqzF4zKKq3sAbasUWHEM6lsMhX+eNN5gkPx1BvLFEnZQEUFbXnGj8Qlp83Pg==", + "version": "3.4.10", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.10.tgz", + "integrity": "sha512-KWZkVPm7yJRhdu4SRSl9d4AK2wM3a50UsvgHZO7xY77NQr2V+fIrEuoDGQcbvswWvFGbS2f6e+jC/6WJm1Dl0w==", "dev": true, "dependencies": { "@alloc/quick-lru": "^5.2.0", diff --git a/examples/pixel/package.json b/examples/pixel/package.json index 5e6b37b..0f07400 100644 --- a/examples/pixel/package.json +++ b/examples/pixel/package.json @@ -11,6 +11,7 @@ "dependencies": { "@bloomreach/discovery-web-sdk": "^1.1.0", "@bloomreach/react-banana-ui": "1.20.0", + "@floating-ui/react": "^0.26.22", "@mui/base": "^5.0.0-beta.40", "@uiw/react-json-view": "^2.0.0-alpha.25", "lodash": "^4.17.21", diff --git a/examples/pixel/src/components/SearchBar.jsx b/examples/pixel/src/components/SearchBar.jsx index 7070a56..0e41285 100644 --- a/examples/pixel/src/components/SearchBar.jsx +++ b/examples/pixel/src/components/SearchBar.jsx @@ -1,23 +1,44 @@ import _ from 'lodash'; -import { Suspense, useEffect, useState } from 'react'; -import Link from 'next/link'; +import { Suspense, useEffect, useRef, useState } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; -import { InputField, LoaderIcon, SearchIcon } from '@bloomreach/react-banana-ui'; -import JsonView from '@uiw/react-json-view'; import Highlighter from 'react-highlight-words'; +import { + autoUpdate, + size, + flip, + useId, + useDismiss, + useFloating, + useInteractions, + useListNavigation, + useRole, + FloatingFocusManager, + FloatingPortal, +} from '@floating-ui/react'; +import { LoaderIcon, SearchIcon } from '@bloomreach/react-banana-ui'; +import JsonView from '@uiw/react-json-view'; import useAnalytics from '../hooks/useAnalytics'; import useAutosuggestApi from '../hooks/useAutosuggestApi'; import { CONFIG } from '../constants'; -function SearchBarInner() { +function SearchBarComponent() { const router = useRouter(); const searchParams = useSearchParams(); const { trackEvent } = useAnalytics(); const [options, setOptions] = useState({}); const [query, setQuery] = useState(''); - const [isInputActive, setIsInputActive] = useState(false); - const [isHoveringResults, setIsHoveringResults] = useState(false); const { loading, error, data } = useAutosuggestApi(CONFIG, options); + const [open, setOpen] = useState(false); + const [activeIndex, setActiveIndex] = useState(null); + const listRef = useRef([]); + + // Aggregate the results in the items, to support keyboard navigation across query and product + // results lists + const results = data?.suggestionGroups?.[0]; + const querySuggestions = _.take(results?.querySuggestions, 6); + const productSuggestions = _.take(results?.searchSuggestions, 6); + + const items = [...querySuggestions, ...productSuggestions]; useEffect(() => { setQuery(searchParams.get('q') || ''); @@ -27,18 +48,60 @@ function SearchBarInner() { setOptions({ q: query }); }, [query]); - const handleSubmit = (e) => { - e.preventDefault(); - trackEvent({ - event: 'event_search', - query, - }); - setIsInputActive(false); - router.push(`/products?q=${query}`); - }; + const { + refs, + floatingStyles, + context, + } = useFloating({ + whileElementsMounted: autoUpdate, + open, + onOpenChange: setOpen, + middleware: [ + flip({ padding: 10 }), + size({ + apply({ rects, availableHeight, elements }) { + Object.assign(elements.floating.style, { + width: `${rects.reference.width}px`, + maxHeight: `${availableHeight}px`, + }); + }, + padding: 10, + }), + ], + }); + + const role = useRole(context, { role: 'listbox' }); + const dismiss = useDismiss(context); + const listNav = useListNavigation(context, { + listRef, + activeIndex, + onNavigate: setActiveIndex, + virtual: true, + loop: true, + }); + + const { + getReferenceProps, + getFloatingProps, + getItemProps, + } = useInteractions([role, dismiss, listNav]); + + // Handle user typing in the search input + function onChange(event) { + const { value } = event.target; + setQuery(value); + + if (value) { + setOpen(true); + setActiveIndex(0); + } else { + setOpen(false); + } + } - const handleQuerySearch = (term) => { - setIsHoveringResults(false); + // Handle when a query term is selected (using keyboard)/clicked + const handleQuerySelect = (term) => { + setOpen(false); trackEvent({ event: 'event_suggest', userQuery: query, @@ -47,110 +110,171 @@ function SearchBarInner() { router.push(`/products?q=${term}`); }; - const isOpen = isInputActive || isHoveringResults; + // Handle when a product is selected (using keyboard)/clicked + const handleProductSelect = (pid) => { + setOpen(false); + router.push(`/products/${pid}`); + }; + + // Handle when user hits `Enter` in the search input + // If something is selected in the search results, use that to determine next action + // If nothing is selected, then do a search using the term entered in the search input + const handleSubmit = (e) => { + e.preventDefault(); + if (activeIndex != null && items[activeIndex]) { + const item = items[activeIndex]; + if (activeIndex < querySuggestions.length) { + handleQuerySelect(item.displayText); + } else { + handleProductSelect(item.pid); + } + setActiveIndex(null); + return; + } + + if (!query) { + return; + } + + trackEvent({ + event: 'event_search', + query, + }); + setOpen(false); + router.push(`/products?q=${query}`); + }; return (
-
- : - } - inputProps={{ id: 'search-field' }} - clearable - fullWidth - helperText="Search for chair, sofa, bed, pillow..." - onFocus={() => setIsInputActive(true)} - onBlur={() => setIsInputActive(false)} - onChange={(e) => setQuery(e.target.value)} - /> - - {isOpen && query ? ( -
setIsHoveringResults(true)} - onMouseLeave={() => setIsHoveringResults(false)} - > -
- {error && } - { - data && (() => { - const [results] = data.suggestionGroups; - const querySuggestions = _.take(results.querySuggestions, 6); - const productSuggestions = _.take(results.searchSuggestions, 6); - - return ( -
-
-
- Query Suggestions -
- {querySuggestions.length ? ( -
    - {querySuggestions.map((suggestion) => ( -
  • handleQuerySearch(suggestion.displayText)} - > - -
  • - ))} -
- ) : ( -
NA
- )} -
- -
-
- Product Suggestions -
- {productSuggestions.length ? ( -
    - {productSuggestions.map((suggestion) => ( -
  • - -
    - +
    + {loading ? : } + + + + {open && ( + +
    + {error && } + { + data && (() => { + return ( +
    +
    +
    + Query Suggestions +
    + {querySuggestions.length ? ( +
      + {querySuggestions.map((suggestion, index) => ( +
    • + -
    - - -
  • - ))} -
- ) : ( -
NA
- )} -
-
- ); - })() - } -
-
- ) : null} + + ))} + + ) : ( +
NA
+ )} +
+ +
+
+ Product Suggestions +
+ {productSuggestions.length ? ( +
    + {productSuggestions.map((suggestion, index) => ( +
  • +
    +
    + +
    + +
    +
  • + ))} +
+ ) : ( +
NA
+ )} +
+ + ); + })() + } + + + )} + + ); } @@ -158,7 +282,7 @@ function SearchBarInner() { export function SearchBar() { return ( - + ); }